mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-08-05 07:03:06 -07:00
mcp: add back
This commit is contained in:
Generated
+146
@@ -182,6 +182,17 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.91"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "atomic-waker"
|
||||
version = "1.1.2"
|
||||
@@ -282,6 +293,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "bech32"
|
||||
version = "0.11.1"
|
||||
@@ -629,6 +646,21 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brk_mcp"
|
||||
version = "0.3.6"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"base64 0.23.1",
|
||||
"brk_logger",
|
||||
"rmcp",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"ureq",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "brk_mempool"
|
||||
version = "0.3.6"
|
||||
@@ -931,6 +963,7 @@ dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
@@ -1676,6 +1709,21 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.33"
|
||||
@@ -1683,6 +1731,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1691,6 +1740,34 @@ version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.33"
|
||||
@@ -1709,8 +1786,13 @@ version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-macro",
|
||||
"futures-sink",
|
||||
"futures-task",
|
||||
"memchr",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
@@ -2596,6 +2678,12 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pathfinder_geometry"
|
||||
version = "0.5.1"
|
||||
@@ -3050,6 +3138,36 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rmcp"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad26b216c966e987e80e86daf784a455c039c43d98575ceed57b8faa259e5695"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.23.1",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pastey",
|
||||
"pin-project-lite",
|
||||
"rand 0.10.2",
|
||||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sse-stream",
|
||||
"thiserror",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tokio-util",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustc-demangle"
|
||||
version = "0.1.28"
|
||||
@@ -3146,6 +3264,7 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"dyn-clone",
|
||||
"indexmap",
|
||||
"ref-cast",
|
||||
@@ -3439,6 +3558,19 @@ dependencies = [
|
||||
"lock_api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-stream"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
@@ -3675,6 +3807,7 @@ version = "1.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
@@ -3694,6 +3827,17 @@ dependencies = [
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.19"
|
||||
@@ -3703,6 +3847,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"libc",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
@@ -3971,6 +4116,7 @@ version = "1.24.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, VecDeque},
|
||||
io,
|
||||
path::Path,
|
||||
};
|
||||
|
||||
use serde::Serialize;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use crate::{Endpoint, Parameter, TypeSchemas, generators::write_if_changed};
|
||||
|
||||
const MANIFEST_SCHEMA_VERSION: u32 = 1;
|
||||
const JSON_SCHEMA_2020_12: &str = "https://json-schema.org/draft/2020-12/schema";
|
||||
const COMPONENT_REF_PREFIX: &str = "#/components/schemas/";
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Manifest {
|
||||
schema_version: u32,
|
||||
operations: Vec<ManifestOperation>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ManifestOperation {
|
||||
tool: ManifestTool,
|
||||
http: ManifestHttpOperation,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ManifestTool {
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
input_schema: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
output_schema: Option<Value>,
|
||||
annotations: ManifestToolAnnotations,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ManifestToolAnnotations {
|
||||
read_only_hint: bool,
|
||||
destructive_hint: bool,
|
||||
idempotent_hint: bool,
|
||||
open_world_hint: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ManifestHttpOperation {
|
||||
method: &'static str,
|
||||
path: String,
|
||||
parameters: Vec<ManifestParameter>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ManifestParameter {
|
||||
name: String,
|
||||
location: ParameterLocation,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum ParameterLocation {
|
||||
Path,
|
||||
Query,
|
||||
}
|
||||
|
||||
/// Generate the immutable machine-readable tool catalog in the LLM bundle.
|
||||
/// Every non-deprecated GET operation is included; all other methods are
|
||||
/// excluded structurally because the current consumer is a read-only cache
|
||||
/// bridge.
|
||||
pub(super) fn generate_tool_manifest(
|
||||
endpoints: &[Endpoint],
|
||||
schemas: &TypeSchemas,
|
||||
path: &Path,
|
||||
) -> io::Result<()> {
|
||||
let content = render_tool_manifest(endpoints, schemas)?;
|
||||
write_if_changed(path, &content)
|
||||
}
|
||||
|
||||
fn render_tool_manifest(endpoints: &[Endpoint], schemas: &TypeSchemas) -> io::Result<String> {
|
||||
let mut names = BTreeSet::new();
|
||||
let mut operations = endpoints
|
||||
.iter()
|
||||
.filter(|endpoint| endpoint.method == "GET" && !endpoint.deprecated)
|
||||
.map(|endpoint| operation_from_endpoint(endpoint, schemas, &mut names))
|
||||
.collect::<io::Result<Vec<_>>>()?;
|
||||
|
||||
operations.sort_by(|a, b| a.tool.name.cmp(&b.tool.name));
|
||||
|
||||
let manifest = Manifest {
|
||||
schema_version: MANIFEST_SCHEMA_VERSION,
|
||||
operations,
|
||||
};
|
||||
let mut content = serde_json::to_string_pretty(&manifest)
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
|
||||
content.push('\n');
|
||||
Ok(content)
|
||||
}
|
||||
|
||||
fn operation_from_endpoint(
|
||||
endpoint: &Endpoint,
|
||||
schemas: &TypeSchemas,
|
||||
names: &mut BTreeSet<String>,
|
||||
) -> io::Result<ManifestOperation> {
|
||||
let name = endpoint.operation_id.clone().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"MCP generation requires a stable operationId for GET {}",
|
||||
endpoint.path
|
||||
),
|
||||
)
|
||||
})?;
|
||||
validate_tool_name(&name)?;
|
||||
if !names.insert(name.clone()) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("duplicate MCP tool name: {name}"),
|
||||
));
|
||||
}
|
||||
|
||||
let input_schema = build_input_schema(endpoint, schemas)?;
|
||||
let output_schema = build_output_schema(endpoint, schemas)?;
|
||||
let parameters = endpoint
|
||||
.path_params
|
||||
.iter()
|
||||
.map(|parameter| manifest_parameter(parameter, ParameterLocation::Path))
|
||||
.chain(
|
||||
endpoint
|
||||
.query_params
|
||||
.iter()
|
||||
.map(|parameter| manifest_parameter(parameter, ParameterLocation::Query)),
|
||||
)
|
||||
.collect();
|
||||
|
||||
Ok(ManifestOperation {
|
||||
tool: ManifestTool {
|
||||
name,
|
||||
title: endpoint.summary.clone(),
|
||||
description: tool_description(endpoint),
|
||||
input_schema,
|
||||
output_schema,
|
||||
annotations: ManifestToolAnnotations {
|
||||
read_only_hint: true,
|
||||
destructive_hint: false,
|
||||
idempotent_hint: true,
|
||||
open_world_hint: true,
|
||||
},
|
||||
},
|
||||
http: ManifestHttpOperation {
|
||||
method: "GET",
|
||||
path: endpoint.path.clone(),
|
||||
parameters,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_description(endpoint: &Endpoint) -> Option<String> {
|
||||
let description = endpoint
|
||||
.description
|
||||
.as_deref()
|
||||
.or(endpoint.summary.as_deref())?;
|
||||
Some(format!(
|
||||
"{description}\n\nREST operation: `{} {}`.",
|
||||
endpoint.method, endpoint.path
|
||||
))
|
||||
}
|
||||
|
||||
fn build_output_schema(endpoint: &Endpoint, schemas: &TypeSchemas) -> io::Result<Option<Value>> {
|
||||
let Some(json_schema) = endpoint.json_response_schema.clone() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Series endpoints can return either JSON or CSV depending on `format`.
|
||||
// Both successful representations must satisfy the advertised contract.
|
||||
let root = if endpoint.supports_csv {
|
||||
json!({
|
||||
"anyOf": [
|
||||
json_schema,
|
||||
{
|
||||
"type": "string",
|
||||
"contentMediaType": "text/csv"
|
||||
}
|
||||
]
|
||||
})
|
||||
} else {
|
||||
json_schema
|
||||
};
|
||||
|
||||
let mut schema = standalone_schema(root, schemas)?;
|
||||
strip_output_annotations(&mut schema);
|
||||
Ok(Some(schema))
|
||||
}
|
||||
|
||||
/// Output schemas are validation contracts, not a second documentation
|
||||
/// bundle. Remove annotation-only keywords while preserving every structural
|
||||
/// and validation keyword. Traversal is schema-aware so an output property
|
||||
/// literally named `description`, `title`, or `default` is never removed.
|
||||
fn strip_output_annotations(schema: &mut Value) {
|
||||
let Some(object) = schema.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
for key in [
|
||||
"title",
|
||||
"description",
|
||||
"default",
|
||||
"examples",
|
||||
"deprecated",
|
||||
"readOnly",
|
||||
"writeOnly",
|
||||
"$comment",
|
||||
] {
|
||||
object.remove(key);
|
||||
}
|
||||
|
||||
for key in [
|
||||
"$defs",
|
||||
"definitions",
|
||||
"properties",
|
||||
"patternProperties",
|
||||
"dependentSchemas",
|
||||
] {
|
||||
if let Some(schemas) = object.get_mut(key).and_then(Value::as_object_mut) {
|
||||
for schema in schemas.values_mut() {
|
||||
strip_output_annotations(schema);
|
||||
}
|
||||
}
|
||||
}
|
||||
for key in [
|
||||
"additionalProperties",
|
||||
"contains",
|
||||
"contentSchema",
|
||||
"else",
|
||||
"if",
|
||||
"items",
|
||||
"not",
|
||||
"propertyNames",
|
||||
"then",
|
||||
"unevaluatedItems",
|
||||
"unevaluatedProperties",
|
||||
] {
|
||||
if let Some(schema) = object.get_mut(key) {
|
||||
strip_output_annotations(schema);
|
||||
}
|
||||
}
|
||||
for key in ["allOf", "anyOf", "oneOf", "prefixItems"] {
|
||||
if let Some(schemas) = object.get_mut(key).and_then(Value::as_array_mut) {
|
||||
for schema in schemas {
|
||||
strip_output_annotations(schema);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn manifest_parameter(parameter: &Parameter, location: ParameterLocation) -> ManifestParameter {
|
||||
ManifestParameter {
|
||||
name: parameter.name.clone(),
|
||||
location,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_input_schema(endpoint: &Endpoint, schemas: &TypeSchemas) -> io::Result<Value> {
|
||||
let mut properties = Map::new();
|
||||
let mut required = Vec::new();
|
||||
|
||||
for parameter in endpoint
|
||||
.path_params
|
||||
.iter()
|
||||
.chain(endpoint.query_params.iter())
|
||||
{
|
||||
let mut schema = if parameter.schema.as_object().is_some_and(Map::is_empty) {
|
||||
schema_from_type_name(¶meter.param_type)
|
||||
} else {
|
||||
parameter.schema.clone()
|
||||
};
|
||||
if let Some(description) = ¶meter.description
|
||||
&& let Some(object) = schema.as_object_mut()
|
||||
{
|
||||
object
|
||||
.entry("description")
|
||||
.or_insert_with(|| Value::String(description.clone()));
|
||||
}
|
||||
properties.insert(parameter.name.clone(), schema);
|
||||
if parameter.required {
|
||||
required.push(Value::String(parameter.name.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
let mut root = Map::from_iter([
|
||||
("type".to_string(), Value::String("object".to_string())),
|
||||
("properties".to_string(), Value::Object(properties)),
|
||||
("additionalProperties".to_string(), Value::Bool(false)),
|
||||
]);
|
||||
if !required.is_empty() {
|
||||
root.insert("required".to_string(), Value::Array(required));
|
||||
}
|
||||
|
||||
standalone_schema(Value::Object(root), schemas)
|
||||
}
|
||||
|
||||
fn schema_from_type_name(name: &str) -> Value {
|
||||
if let Some(inner) = name.strip_suffix("[]") {
|
||||
return json!({
|
||||
"type": "array",
|
||||
"items": schema_from_type_name(inner)
|
||||
});
|
||||
}
|
||||
|
||||
match name {
|
||||
"*" | "Object" => json!({}),
|
||||
"string" => json!({ "type": "string" }),
|
||||
"number" => json!({ "type": "number" }),
|
||||
"integer" => json!({ "type": "integer" }),
|
||||
"boolean" => json!({ "type": "boolean" }),
|
||||
"null" => json!({ "type": "null" }),
|
||||
other => json!({ "$ref": format!("{COMPONENT_REF_PREFIX}{other}") }),
|
||||
}
|
||||
}
|
||||
|
||||
/// Turn an OpenAPI schema into a self-contained JSON Schema. MCP clients must
|
||||
/// not fetch external `$ref` targets, so component references are rewritten to
|
||||
/// local `$defs` and only the transitively used definitions are copied.
|
||||
fn standalone_schema(mut root: Value, schemas: &TypeSchemas) -> io::Result<Value> {
|
||||
if root.is_boolean() {
|
||||
root = if root == Value::Bool(true) {
|
||||
json!({})
|
||||
} else {
|
||||
json!({ "not": {} })
|
||||
};
|
||||
}
|
||||
if !root.is_object() {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"MCP schemas must be JSON objects",
|
||||
));
|
||||
}
|
||||
|
||||
let mut pending = VecDeque::new();
|
||||
let mut seen = BTreeSet::new();
|
||||
collect_component_refs(&root, &mut pending)?;
|
||||
|
||||
let mut definitions = BTreeMap::new();
|
||||
while let Some(name) = pending.pop_front() {
|
||||
if !seen.insert(name.clone()) {
|
||||
continue;
|
||||
}
|
||||
let schema = schemas.get(&name).ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("OpenAPI schema references missing component {name}"),
|
||||
)
|
||||
})?;
|
||||
collect_component_refs(schema, &mut pending)?;
|
||||
definitions.insert(name, schema.clone());
|
||||
}
|
||||
|
||||
rewrite_component_refs(&mut root)?;
|
||||
for schema in definitions.values_mut() {
|
||||
rewrite_component_refs(schema)?;
|
||||
}
|
||||
|
||||
let object = root.as_object_mut().expect("root checked above");
|
||||
object.insert(
|
||||
"$schema".to_string(),
|
||||
Value::String(JSON_SCHEMA_2020_12.to_string()),
|
||||
);
|
||||
if !definitions.is_empty() {
|
||||
object.insert(
|
||||
"$defs".to_string(),
|
||||
Value::Object(definitions.into_iter().collect()),
|
||||
);
|
||||
}
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
fn collect_component_refs(value: &Value, pending: &mut VecDeque<String>) -> io::Result<()> {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
if let Some(reference) = object.get("$ref").and_then(Value::as_str) {
|
||||
if let Some(name) = reference.strip_prefix(COMPONENT_REF_PREFIX) {
|
||||
pending.push_back(unescape_json_pointer(name));
|
||||
} else if !reference.starts_with("#/$defs/") && !reference.starts_with("#/") {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("external JSON Schema reference is not supported: {reference}"),
|
||||
));
|
||||
}
|
||||
}
|
||||
for nested in object.values() {
|
||||
collect_component_refs(nested, pending)?;
|
||||
}
|
||||
}
|
||||
Value::Array(array) => {
|
||||
for nested in array {
|
||||
collect_component_refs(nested, pending)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rewrite_component_refs(value: &mut Value) -> io::Result<()> {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
if let Some(reference) = object.get_mut("$ref") {
|
||||
let replacement = reference
|
||||
.as_str()
|
||||
.and_then(|reference| reference.strip_prefix(COMPONENT_REF_PREFIX))
|
||||
.map(|name| {
|
||||
format!(
|
||||
"#/$defs/{}",
|
||||
escape_json_pointer(&unescape_json_pointer(name))
|
||||
)
|
||||
});
|
||||
if let Some(replacement) = replacement {
|
||||
*reference = Value::String(replacement);
|
||||
}
|
||||
}
|
||||
for nested in object.values_mut() {
|
||||
rewrite_component_refs(nested)?;
|
||||
}
|
||||
}
|
||||
Value::Array(array) => {
|
||||
for nested in array {
|
||||
rewrite_component_refs(nested)?;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_tool_name(name: &str) -> io::Result<()> {
|
||||
let valid = (1..=128).contains(&name.len())
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'));
|
||||
if valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
format!("invalid MCP tool name: {name}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_json_pointer(value: &str) -> String {
|
||||
value.replace('~', "~0").replace('/', "~1")
|
||||
}
|
||||
|
||||
fn unescape_json_pointer(value: &str) -> String {
|
||||
value.replace("~1", "/").replace("~0", "~")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Parameter, ResponseKind, TextSchema};
|
||||
|
||||
fn endpoint(name: &str, method: &str) -> Endpoint {
|
||||
Endpoint {
|
||||
method: method.to_string(),
|
||||
path: "/api/items/{id}".to_string(),
|
||||
operation_id: Some(name.to_string()),
|
||||
summary: Some("Read item".to_string()),
|
||||
description: Some("Returns one item.".to_string()),
|
||||
path_params: vec![Parameter {
|
||||
name: "id".to_string(),
|
||||
required: true,
|
||||
param_type: "ItemId".to_string(),
|
||||
description: Some("Item identifier".to_string()),
|
||||
schema: json!({ "$ref": "#/components/schemas/ItemId" }),
|
||||
}],
|
||||
query_params: Vec::new(),
|
||||
request_body: None,
|
||||
response_kind: ResponseKind::Text(Some(TextSchema {
|
||||
name: "ItemId".to_string(),
|
||||
is_numeric: true,
|
||||
})),
|
||||
json_response_schema: Some(json!({
|
||||
"$ref": "#/components/schemas/Item"
|
||||
})),
|
||||
deprecated: false,
|
||||
supports_csv: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_only_active_gets_in_name_order() {
|
||||
let mut post = endpoint("post_item", "POST");
|
||||
post.path = "/api/items".to_string();
|
||||
let mut deprecated = endpoint("old_item", "GET");
|
||||
deprecated.deprecated = true;
|
||||
let get = endpoint("get_item", "GET");
|
||||
let schemas = BTreeMap::from([
|
||||
("ItemId".to_string(), json!({ "type": "integer" })),
|
||||
(
|
||||
"Item".to_string(),
|
||||
json!({
|
||||
"description": "Item response documentation",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "$ref": "#/components/schemas/ItemId" },
|
||||
"description": {
|
||||
"description": "Human-readable item description",
|
||||
"type": "string",
|
||||
"examples": ["example"],
|
||||
"default": "example"
|
||||
}
|
||||
},
|
||||
"required": ["id"]
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
let manifest = render_tool_manifest(&[post, deprecated, get], &schemas).unwrap();
|
||||
let value: Value = serde_json::from_str(&manifest).unwrap();
|
||||
let operations = value["operations"].as_array().unwrap();
|
||||
|
||||
assert_eq!(operations.len(), 1);
|
||||
assert_eq!(operations[0]["tool"]["name"], "get_item");
|
||||
assert_eq!(operations[0]["http"]["method"], "GET");
|
||||
assert_eq!(
|
||||
operations[0]["tool"]["description"],
|
||||
"Returns one item.\n\nREST operation: `GET /api/items/{id}`."
|
||||
);
|
||||
assert_eq!(
|
||||
operations[0]["tool"]["inputSchema"]["properties"]["id"]["$ref"],
|
||||
"#/$defs/ItemId"
|
||||
);
|
||||
assert_eq!(
|
||||
operations[0]["tool"]["inputSchema"]["$defs"]["ItemId"]["type"],
|
||||
"integer"
|
||||
);
|
||||
assert_eq!(
|
||||
operations[0]["tool"]["outputSchema"]["$ref"],
|
||||
"#/$defs/Item"
|
||||
);
|
||||
assert_eq!(
|
||||
operations[0]["tool"]["outputSchema"]["$defs"]["Item"]["properties"]["id"]["$ref"],
|
||||
"#/$defs/ItemId"
|
||||
);
|
||||
assert_eq!(
|
||||
operations[0]["tool"]["outputSchema"]["$defs"]["ItemId"]["type"],
|
||||
"integer"
|
||||
);
|
||||
assert!(
|
||||
operations[0]["tool"]["outputSchema"]["$defs"]["Item"]
|
||||
.get("description")
|
||||
.is_none()
|
||||
);
|
||||
let description_property =
|
||||
&operations[0]["tool"]["outputSchema"]["$defs"]["Item"]["properties"]["description"];
|
||||
assert_eq!(description_property["type"], "string");
|
||||
assert!(description_property.get("description").is_none());
|
||||
assert!(description_property.get("examples").is_none());
|
||||
assert!(description_property.get("default").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn csv_output_schema_accepts_json_or_csv_text() {
|
||||
let mut endpoint = endpoint("get_item", "GET");
|
||||
endpoint.supports_csv = true;
|
||||
let schemas = BTreeMap::from([
|
||||
("ItemId".to_string(), json!({ "type": "integer" })),
|
||||
(
|
||||
"Item".to_string(),
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "$ref": "#/components/schemas/ItemId" }
|
||||
}
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
let manifest = render_tool_manifest(&[endpoint], &schemas).unwrap();
|
||||
let value: Value = serde_json::from_str(&manifest).unwrap();
|
||||
let output = &value["operations"][0]["tool"]["outputSchema"];
|
||||
|
||||
assert_eq!(output["anyOf"][0]["$ref"], "#/$defs/Item");
|
||||
assert_eq!(output["anyOf"][1]["type"], "string");
|
||||
assert_eq!(output["anyOf"][1]["contentMediaType"], "text/csv");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omits_output_schema_without_a_json_response_schema() {
|
||||
let mut endpoint = endpoint("get_item", "GET");
|
||||
endpoint.json_response_schema = None;
|
||||
let schemas = BTreeMap::from([("ItemId".to_string(), json!({ "type": "integer" }))]);
|
||||
|
||||
let manifest = render_tool_manifest(&[endpoint], &schemas).unwrap();
|
||||
let value: Value = serde_json::from_str(&manifest).unwrap();
|
||||
|
||||
assert!(value["operations"][0]["tool"].get("outputSchema").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_missing_operation_id() {
|
||||
let mut endpoint = endpoint("get_item", "GET");
|
||||
endpoint.operation_id = None;
|
||||
let error = render_tool_manifest(&[endpoint], &BTreeMap::new()).unwrap_err();
|
||||
assert!(error.to_string().contains("requires a stable operationId"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_operation_ids() {
|
||||
let first = endpoint("get_item", "GET");
|
||||
let second = endpoint("get_item", "GET");
|
||||
let schemas = BTreeMap::from([
|
||||
("ItemId".to_string(), json!({ "type": "integer" })),
|
||||
("Item".to_string(), json!({ "type": "object" })),
|
||||
]);
|
||||
let error = render_tool_manifest(&[first, second], &schemas).unwrap_err();
|
||||
assert!(error.to_string().contains("duplicate MCP tool name"));
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ use crate::{ClientMetadata, Endpoint, ResponseKind, TypeSchemas};
|
||||
|
||||
use super::write_if_changed;
|
||||
|
||||
mod manifest;
|
||||
|
||||
const BASE_URL: &str = "https://bitview.space";
|
||||
|
||||
pub fn generate_llm_clients(
|
||||
@@ -22,6 +24,7 @@ pub fn generate_llm_clients(
|
||||
endpoints: &[Endpoint],
|
||||
schemas: &TypeSchemas,
|
||||
roots: &[PathBuf],
|
||||
manifest_path: Option<&Path>,
|
||||
) -> io::Result<()> {
|
||||
let metric_count = count_metrics(&metadata.catalog);
|
||||
let generated = endpoints
|
||||
@@ -47,6 +50,12 @@ pub fn generate_llm_clients(
|
||||
write_output(&root.join("llms.txt"), &llms)?;
|
||||
write_output(&root.join("llms-full.txt"), &llms_full)?;
|
||||
}
|
||||
if let Some(path) = manifest_path {
|
||||
if let Some(parent) = path.parent() {
|
||||
create_dir_all(parent)?;
|
||||
}
|
||||
manifest::generate_tool_manifest(endpoints, schemas, path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -440,10 +449,14 @@ mod tests {
|
||||
required: true,
|
||||
param_type: "string".to_owned(),
|
||||
description: Some("Thing identifier".to_owned()),
|
||||
schema: serde_json::json!({ "type": "string" }),
|
||||
}],
|
||||
query_params: Vec::new(),
|
||||
request_body: None,
|
||||
response_kind: ResponseKind::Json("Thing".to_owned()),
|
||||
json_response_schema: Some(serde_json::json!({
|
||||
"$ref": "#/components/schemas/Thing"
|
||||
})),
|
||||
deprecated: false,
|
||||
supports_csv: false,
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ use brk_query::Vecs;
|
||||
/// .rust("crates/brk_client/src/lib.rs")
|
||||
/// .javascript("modules/brk-client/index.js")
|
||||
/// .python("packages/brk_client/__init__.py")
|
||||
/// .llm_manifest("crates/brk_mcp/generated/manifest.json")
|
||||
/// .llm("website")
|
||||
/// .llm("website_next");
|
||||
/// ```
|
||||
@@ -29,6 +30,8 @@ pub struct ClientOutputPaths {
|
||||
pub python: Option<PathBuf>,
|
||||
/// Root directories for generated LLM client bundles.
|
||||
pub llm: Vec<PathBuf>,
|
||||
/// Full path to the machine-readable tool manifest in the LLM bundle.
|
||||
pub llm_manifest: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl ClientOutputPaths {
|
||||
@@ -55,6 +58,11 @@ impl ClientOutputPaths {
|
||||
self.llm.push(root.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn llm_manifest(mut self, path: impl Into<PathBuf>) -> Self {
|
||||
self.llm_manifest = Some(path.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
mod analysis;
|
||||
@@ -86,6 +94,7 @@ pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
/// .rust("crates/brk_client/src/lib.rs")
|
||||
/// .javascript("modules/brk-client/index.js")
|
||||
/// .python("packages/brk_client/__init__.py")
|
||||
/// .llm_manifest("crates/brk_mcp/generated/manifest.json")
|
||||
/// .llm("website")
|
||||
/// .llm("website_next");
|
||||
///
|
||||
@@ -137,7 +146,14 @@ pub fn generate_clients(
|
||||
generate_python_client(&metadata, &endpoints, &schemas, python_path)?;
|
||||
}
|
||||
|
||||
generate_llm_clients(&metadata, &spec, &endpoints, &schemas, &output_paths.llm)?;
|
||||
generate_llm_clients(
|
||||
&metadata,
|
||||
&spec,
|
||||
&endpoints,
|
||||
&schemas,
|
||||
&output_paths.llm,
|
||||
output_paths.llm_manifest.as_deref(),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::openapi::{Parameter, ResponseKind};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Request body shape for POST/PUT/PATCH endpoints.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -30,6 +31,8 @@ pub struct Endpoint {
|
||||
pub request_body: Option<RequestBody>,
|
||||
/// Body kind for the 200 response.
|
||||
pub response_kind: ResponseKind,
|
||||
/// Raw JSON schema for the application/json 200 response, when present.
|
||||
pub json_response_schema: Option<Value>,
|
||||
/// Whether this endpoint is deprecated
|
||||
pub deprecated: bool,
|
||||
/// Whether this endpoint supports CSV format (text/csv content type)
|
||||
|
||||
@@ -129,6 +129,7 @@ fn extract_endpoint(
|
||||
let query_params = extract_parameters(operation, ParameterIn::Query);
|
||||
|
||||
let response_kind = extract_response_kind(operation, spec);
|
||||
let json_response_schema = extract_json_response_schema(operation);
|
||||
let request_body = extract_request_body(operation);
|
||||
let supports_csv = check_csv_support(operation);
|
||||
|
||||
@@ -142,11 +143,29 @@ fn extract_endpoint(
|
||||
query_params,
|
||||
request_body,
|
||||
response_kind,
|
||||
json_response_schema,
|
||||
deprecated: operation.deprecated.unwrap_or(false),
|
||||
supports_csv,
|
||||
})
|
||||
}
|
||||
|
||||
/// Preserve the complete JSON response schema for MCP output schema
|
||||
/// generation. The regular clients only need [`ResponseKind`], while MCP
|
||||
/// clients benefit from the exact self-contained response shape.
|
||||
fn extract_json_response_schema(operation: &Operation) -> Option<Value> {
|
||||
let response =
|
||||
operation
|
||||
.responses
|
||||
.as_ref()?
|
||||
.get("200")
|
||||
.and_then(|response| match response {
|
||||
ObjectOrReference::Object(response) => Some(response),
|
||||
ObjectOrReference::Ref { .. } => None,
|
||||
})?;
|
||||
let schema = response.content.get("application/json")?.schema.as_ref()?;
|
||||
serde_json::to_value(schema).ok()
|
||||
}
|
||||
|
||||
/// Extract the request body shape, if any.
|
||||
/// Prefers `text/plain` (string) over `application/json` (typed).
|
||||
fn extract_request_body(operation: &Operation) -> Option<RequestBody> {
|
||||
@@ -225,6 +244,11 @@ fn extract_parameters(operation: &Operation, location: ParameterIn) -> Vec<Param
|
||||
required: param.required.unwrap_or(false),
|
||||
param_type,
|
||||
description: param.description.clone(),
|
||||
schema: param
|
||||
.schema
|
||||
.as_ref()
|
||||
.and_then(|schema| serde_json::to_value(schema).ok())
|
||||
.unwrap_or_else(|| Value::Object(serde_json::Map::new())),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use serde_json::Value;
|
||||
|
||||
/// Parameter information.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Parameter {
|
||||
@@ -5,4 +7,6 @@ pub struct Parameter {
|
||||
pub required: bool,
|
||||
pub param_type: String,
|
||||
pub description: Option<String>,
|
||||
/// Original OpenAPI/JSON Schema for schema-driven generators.
|
||||
pub schema: Value,
|
||||
}
|
||||
|
||||
@@ -9842,7 +9842,7 @@ impl BrkClient {
|
||||
|
||||
/// Health check
|
||||
///
|
||||
/// Liveness probe. Returns server identity, uptime, and indexed/computed heights from local state only (no bitcoind round-trip). For real chain-tip catch-up, see `/api/server/sync`.
|
||||
/// Liveness probe. Returns server identity, uptime, and indexed/computed heights from local state only (no bitcoind round-trip). For real chain-tip catch-up, request `GET /api/server/sync`.
|
||||
///
|
||||
/// Endpoint: `GET /health`
|
||||
pub fn get_health(&self) -> Result<Health> {
|
||||
@@ -9962,7 +9962,7 @@ impl BrkClient {
|
||||
|
||||
/// Get raw series data
|
||||
///
|
||||
/// Returns just the data array without the SeriesData wrapper. Supports the same range and format parameters as the standard endpoint.
|
||||
/// Returns just the data array without the SeriesData wrapper. Supports the same range and format parameters as `GET /api/series/{series}/{index}`.
|
||||
///
|
||||
/// Endpoint: `GET /api/series/{series}/{index}/data`
|
||||
pub fn get_series_data(&self, series: SeriesName, index: Index, start: Option<RangeIndex>, end: Option<RangeIndex>, limit: Option<Limit>, format: Option<Format>) -> Result<FormatResponse<Vec<bool>>> {
|
||||
@@ -10053,9 +10053,7 @@ impl BrkClient {
|
||||
|
||||
/// Latest URPD
|
||||
///
|
||||
/// URPD for the most recent available date in the cohort. The response's `date` field echoes which date was served.
|
||||
///
|
||||
/// See the URPD tag description for the response shape, `agg`, and `weight` options.
|
||||
/// URPD for the most recent available date in the cohort. The response's `date` field echoes which date was served. Returns `{ cohort, date, weight, aggregation, close, total_supply, buckets }`. `close` and each bucket's `price_floor`, `realized_cap`, and `unrealized_pnl` are USD; `total_supply` and bucket `supply` are BTC. `unrealized_pnl` can be negative.
|
||||
///
|
||||
/// Endpoint: `GET /api/urpd/{cohort}`
|
||||
pub fn get_urpd(&self, cohort: Cohort, agg: Option<UrpdAggregation>, weight: Option<UrpdWeight>) -> Result<Urpd> {
|
||||
@@ -10069,9 +10067,7 @@ impl BrkClient {
|
||||
|
||||
/// URPD at date
|
||||
///
|
||||
/// URPD for a (cohort, date) pair. Returns `{ cohort, date, weight, aggregation, close, total_supply, buckets }` where each bucket is `{ price_floor, supply, realized_cap, unrealized_pnl }`.
|
||||
///
|
||||
/// See the URPD tag description for unit conventions, `agg`, and `weight` options.
|
||||
/// URPD for a (cohort, date) pair. Returns `{ cohort, date, weight, aggregation, close, total_supply, buckets }` where each bucket is `{ price_floor, supply, realized_cap, unrealized_pnl }`. `close`, `price_floor`, `realized_cap`, and `unrealized_pnl` are USD; `total_supply` and `supply` are BTC. `unrealized_pnl` can be negative.
|
||||
///
|
||||
/// Endpoint: `GET /api/urpd/{cohort}/{date}`
|
||||
pub fn get_urpd_at(&self, cohort: Cohort, date: &str, agg: Option<UrpdAggregation>, weight: Option<UrpdWeight>) -> Result<Urpd> {
|
||||
@@ -10122,7 +10118,7 @@ impl BrkClient {
|
||||
|
||||
/// Address hash-prefix matches
|
||||
///
|
||||
/// Find addresses by address type and by the first 1-16 hex nibbles of RapidHash v3 over the raw address payload bytes. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata for the returned addresses through `/api/address/{address}`.
|
||||
/// Find addresses by address type and by the first 1-16 hex nibbles of RapidHash v3 over the raw address payload bytes. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata with `GET /api/address/{address}`.
|
||||
///
|
||||
/// Endpoint: `GET /api/address/hash-prefix/{addr_type}/{prefix}`
|
||||
pub fn get_address_hash_prefix_matches(&self, addr_type: OutputType, prefix: &str) -> Result<AddrHashPrefixMatches> {
|
||||
@@ -10143,7 +10139,7 @@ impl BrkClient {
|
||||
|
||||
/// Address transactions
|
||||
///
|
||||
/// Get transaction history for an address, newest first. Returns up to 50 mempool transactions plus a confirmed page sized to fill the response to 50 total (chain floor of 25, so 25-50 confirmed depending on mempool weight). To paginate further confirmed history, use `/address/{address}/txs/chain/{last_seen_txid}`.
|
||||
/// Get transaction history for an address, newest first. Returns up to 50 mempool transactions plus a confirmed page sized to fill the response to 50 total (chain floor of 25, so 25-50 confirmed depending on mempool weight). To paginate further confirmed history, request `GET /api/address/{address}/txs/chain/{after_txid}` with the last returned txid.
|
||||
///
|
||||
/// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*
|
||||
///
|
||||
@@ -10154,7 +10150,7 @@ impl BrkClient {
|
||||
|
||||
/// Address confirmed transactions
|
||||
///
|
||||
/// Get the first 25 confirmed transactions for an address. For pagination, use the path-style form `/txs/chain/{last_seen_txid}`.
|
||||
/// Get the first 25 confirmed transactions for an address. For pagination, request `GET /api/address/{address}/txs/chain/{after_txid}` with the last returned txid.
|
||||
///
|
||||
/// *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)*
|
||||
///
|
||||
@@ -10669,7 +10665,7 @@ impl BrkClient {
|
||||
|
||||
/// Recent full-RBF replacements
|
||||
///
|
||||
/// Like `/api/v1/replacements`, but limited to trees where at least one predecessor was non-signaling (full-RBF).
|
||||
/// Same response shape as `GET /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)*
|
||||
///
|
||||
@@ -10680,7 +10676,7 @@ impl BrkClient {
|
||||
|
||||
/// Projected next block template
|
||||
///
|
||||
/// Bitcoin Core's `getblocktemplate` selection: full transaction bodies in GBT order with aggregate stats. The returned `hash` is an opaque content token; pass it as `<hash>` on `/api/v1/mempool/block-template/diff/{hash}` to fetch deltas instead of refetching the whole template.
|
||||
/// Bitcoin Core's `getblocktemplate` selection: full transaction bodies in GBT order with aggregate stats. The returned `hash` is an opaque content token; pass it to `GET /api/v1/mempool/block-template/diff/{hash}` to fetch deltas instead of refetching the whole template.
|
||||
///
|
||||
/// Endpoint: `GET /api/v1/mempool/block-template`
|
||||
pub fn get_block_template(&self) -> Result<BlockTemplate> {
|
||||
@@ -10689,7 +10685,7 @@ impl BrkClient {
|
||||
|
||||
/// Block template diff since hash
|
||||
///
|
||||
/// Delta of the projected next block since `<hash>`. `order` is the full new template in order: each entry is either a number (index into the prior template the client cached at `<hash>`) or a transaction object (new body to insert at this position). Walk `order` once to rebuild; `removed` is a convenience list of txids that left so clients can evict cached bodies. After applying, use the response `hash` as `<hash>` on the next call to keep iterating. Returns `404` when `<hash>` has aged out of server history; clients should fall back to `/api/v1/mempool/block-template`.
|
||||
/// Delta of the projected next block since `<hash>`. `order` is the full new template in order: each entry is either a number (index into the prior template the client cached at `<hash>`) or a transaction object (new body to insert at this position). Walk `order` once to rebuild; `removed` is a convenience list of txids that left so clients can evict cached bodies. After applying, use the response `hash` as `<hash>` on the next call to keep iterating. Returns `404` when `<hash>` has aged out of server history; clients should fall back to `GET /api/v1/mempool/block-template`.
|
||||
///
|
||||
/// Endpoint: `GET /api/v1/mempool/block-template/diff/{hash}`
|
||||
pub fn get_block_template_diff(&self, hash: NextBlockHash) -> Result<BlockTemplateDiff> {
|
||||
@@ -10707,7 +10703,7 @@ impl BrkClient {
|
||||
|
||||
/// Live BTC/USD price
|
||||
///
|
||||
/// Current BTC/USD price in dollars. Same value as `/api/mempool/price`. Confirmed per-height history is available at `/api/vecs/height-to-price`.
|
||||
/// Current BTC/USD price in dollars. Same value as `GET /api/mempool/price`. Confirmed per-height history is available at `GET /api/series/price/height`.
|
||||
///
|
||||
/// Endpoint: `GET /api/oracle/price`
|
||||
pub fn get_oracle_price(&self) -> Result<Dollars> {
|
||||
@@ -10906,7 +10902,7 @@ impl BrkClient {
|
||||
|
||||
/// Compact OpenAPI specification
|
||||
///
|
||||
/// Compact OpenAPI specification optimized for LLM consumption. Removes redundant fields while preserving essential API information. Full spec available at `/openapi.json`.
|
||||
/// Compact OpenAPI specification optimized for LLM consumption. Removes redundant fields while preserving essential API information. The full specification is available at `GET /openapi.json`.
|
||||
///
|
||||
/// Endpoint: `GET /api.json`
|
||||
pub fn get_api(&self) -> Result<serde_json::Value> {
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
use std::{collections::HashSet, env, path::Path, time::Instant};
|
||||
|
||||
use brk_indexer::Indexer;
|
||||
use brk_types::{Height, OutputType, TypeIndex};
|
||||
use vecdb::{AnyVec, ReadableVec};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Counts {
|
||||
events: u64,
|
||||
new: u64,
|
||||
cached: u64,
|
||||
disk_reads: u64,
|
||||
unique_disk_reads: u64,
|
||||
}
|
||||
|
||||
fn first_index(indexer: &Indexer, output_type: OutputType, height: Height) -> TypeIndex {
|
||||
match output_type {
|
||||
OutputType::P2PK65 => indexer.vecs.addrs.p2pk65.first_index.collect_one(height).into(),
|
||||
OutputType::P2PK33 => indexer.vecs.addrs.p2pk33.first_index.collect_one(height).into(),
|
||||
OutputType::P2PKH => indexer.vecs.addrs.p2pkh.first_index.collect_one(height).into(),
|
||||
OutputType::P2SH => indexer.vecs.addrs.p2sh.first_index.collect_one(height).into(),
|
||||
OutputType::P2WPKH => indexer.vecs.addrs.p2wpkh.first_index.collect_one(height).into(),
|
||||
OutputType::P2WSH => indexer.vecs.addrs.p2wsh.first_index.collect_one(height).into(),
|
||||
OutputType::P2TR => indexer.vecs.addrs.p2tr.first_index.collect_one(height).into(),
|
||||
OutputType::P2A => indexer.vecs.addrs.p2a.first_index.collect_one(height).into(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let outputs_dir = Path::new(&env::var("HOME").unwrap()).join(".brk");
|
||||
let indexer = Indexer::forced_import(&outputs_dir).unwrap();
|
||||
|
||||
let from = 950_001usize;
|
||||
let to = 960_001usize;
|
||||
let start = Instant::now();
|
||||
|
||||
let output_starts = indexer
|
||||
.vecs
|
||||
.outputs
|
||||
.first_txout_index
|
||||
.collect_range_at(from, to + 1);
|
||||
let input_starts = indexer
|
||||
.vecs
|
||||
.inputs
|
||||
.first_txin_index
|
||||
.collect_range_at(from, to + 1);
|
||||
|
||||
let output_from = output_starts[0].to_usize();
|
||||
let output_types = indexer
|
||||
.vecs
|
||||
.outputs
|
||||
.output_type
|
||||
.collect_range_at(output_from, output_starts.last().unwrap().to_usize());
|
||||
let output_indexes = indexer
|
||||
.vecs
|
||||
.outputs
|
||||
.type_index
|
||||
.collect_range_at(output_from, output_starts.last().unwrap().to_usize());
|
||||
|
||||
let input_from = input_starts[0].to_usize();
|
||||
let input_types = indexer
|
||||
.vecs
|
||||
.inputs
|
||||
.output_type
|
||||
.collect_range_at(input_from, input_starts.last().unwrap().to_usize());
|
||||
let input_indexes = indexer
|
||||
.vecs
|
||||
.inputs
|
||||
.type_index
|
||||
.collect_range_at(input_from, input_starts.last().unwrap().to_usize());
|
||||
|
||||
let mut cache = HashSet::new();
|
||||
let mut counts = Counts::default();
|
||||
|
||||
for block_offset in 0..to - from {
|
||||
let height = Height::from(from + block_offset);
|
||||
let first_indexes = OutputType::ADDR_TYPES.map(|output_type| {
|
||||
(
|
||||
output_type,
|
||||
first_index(&indexer, output_type, height),
|
||||
)
|
||||
});
|
||||
let mut block_misses = HashSet::new();
|
||||
let mut block_addresses = HashSet::new();
|
||||
|
||||
let output_start = output_starts[block_offset].to_usize() - output_from;
|
||||
let output_end = output_starts[block_offset + 1].to_usize() - output_from;
|
||||
let output_items = output_types[output_start..output_end]
|
||||
.iter()
|
||||
.copied()
|
||||
.zip(output_indexes[output_start..output_end].iter().copied());
|
||||
|
||||
let input_start = input_starts[block_offset].to_usize() - input_from + 1;
|
||||
let input_end = input_starts[block_offset + 1].to_usize() - input_from;
|
||||
let input_items = input_types[input_start..input_end]
|
||||
.iter()
|
||||
.copied()
|
||||
.zip(input_indexes[input_start..input_end].iter().copied());
|
||||
|
||||
for (output_type, type_index) in output_items.chain(input_items) {
|
||||
if !output_type.is_addr() {
|
||||
continue;
|
||||
}
|
||||
counts.events += 1;
|
||||
let key = (output_type, type_index);
|
||||
block_addresses.insert(key);
|
||||
|
||||
let first = first_indexes
|
||||
.iter()
|
||||
.find_map(|(kind, first)| (*kind == output_type).then_some(*first))
|
||||
.unwrap();
|
||||
if type_index >= first {
|
||||
counts.new += 1;
|
||||
} else if cache.contains(&key) {
|
||||
counts.cached += 1;
|
||||
} else {
|
||||
counts.disk_reads += 1;
|
||||
block_misses.insert(key);
|
||||
}
|
||||
}
|
||||
|
||||
counts.unique_disk_reads += block_misses.len() as u64;
|
||||
cache.extend(block_addresses);
|
||||
}
|
||||
|
||||
println!("elapsed={:?}", start.elapsed());
|
||||
println!("events={}", counts.events);
|
||||
println!("new={}", counts.new);
|
||||
println!("cached={}", counts.cached);
|
||||
println!("disk_reads={}", counts.disk_reads);
|
||||
println!("unique_disk_reads={}", counts.unique_disk_reads);
|
||||
println!(
|
||||
"redundant_disk_reads={} ({:.2}%)",
|
||||
counts.disk_reads - counts.unique_disk_reads,
|
||||
100.0 * (counts.disk_reads - counts.unique_disk_reads) as f64 / counts.disk_reads as f64
|
||||
);
|
||||
}
|
||||
@@ -40,25 +40,6 @@ impl<T> AddrTypeToTypeIndexMap<T> {
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_single(own: &mut FxHashMap<TypeIndex, T>, other: &mut FxHashMap<TypeIndex, T>) {
|
||||
if own.len() < other.len() {
|
||||
mem::swap(own, other);
|
||||
}
|
||||
own.extend(other.drain());
|
||||
}
|
||||
|
||||
/// Merge another map into self, consuming other.
|
||||
pub(crate) fn merge_mut(&mut self, mut other: Self) {
|
||||
Self::merge_single(&mut self.p2a, &mut other.p2a);
|
||||
Self::merge_single(&mut self.p2pk33, &mut other.p2pk33);
|
||||
Self::merge_single(&mut self.p2pk65, &mut other.p2pk65);
|
||||
Self::merge_single(&mut self.p2pkh, &mut other.p2pkh);
|
||||
Self::merge_single(&mut self.p2sh, &mut other.p2sh);
|
||||
Self::merge_single(&mut self.p2tr, &mut other.p2tr);
|
||||
Self::merge_single(&mut self.p2wpkh, &mut other.p2wpkh);
|
||||
Self::merge_single(&mut self.p2wsh, &mut other.p2wsh);
|
||||
}
|
||||
|
||||
/// Insert a value for a specific address type and type_index.
|
||||
pub(crate) fn insert_for_type(
|
||||
&mut self,
|
||||
|
||||
+92
-39
@@ -12,12 +12,49 @@ use crate::distribution::{
|
||||
use super::super::cohort::{WithAddrDataSource, update_tx_counts};
|
||||
use super::lookup::AddrLookup;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[repr(transparent)]
|
||||
struct BlockAddress(u64);
|
||||
|
||||
impl BlockAddress {
|
||||
const TYPE_SHIFT: u32 = u32::BITS;
|
||||
|
||||
#[inline(always)]
|
||||
fn new(addr_type: OutputType, type_index: TypeIndex) -> Self {
|
||||
debug_assert!(addr_type.is_addr());
|
||||
|
||||
Self((u64::from(addr_type as u8) << Self::TYPE_SHIFT) | u64::from(u32::from(type_index)))
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn addr_type(self) -> OutputType {
|
||||
match (self.0 >> Self::TYPE_SHIFT) as u8 {
|
||||
value if value == OutputType::P2PK65 as u8 => OutputType::P2PK65,
|
||||
value if value == OutputType::P2PK33 as u8 => OutputType::P2PK33,
|
||||
value if value == OutputType::P2PKH as u8 => OutputType::P2PKH,
|
||||
value if value == OutputType::P2SH as u8 => OutputType::P2SH,
|
||||
value if value == OutputType::P2WPKH as u8 => OutputType::P2WPKH,
|
||||
value if value == OutputType::P2WSH as u8 => OutputType::P2WSH,
|
||||
value if value == OutputType::P2TR as u8 => OutputType::P2TR,
|
||||
value if value == OutputType::P2A as u8 => OutputType::P2A,
|
||||
_ => unreachable!("BlockAddress only stores address output types"),
|
||||
}
|
||||
}
|
||||
|
||||
#[inline(always)]
|
||||
fn type_index(self) -> TypeIndex {
|
||||
TypeIndex::from(self.0 as u32)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache for address data within a flush interval.
|
||||
pub struct AddrCache {
|
||||
/// Addrs with non-zero balance
|
||||
funded: AddrTypeToTypeIndexMap<WithAddrDataSource<FundedAddrData>>,
|
||||
/// Addrs that became empty (zero balance)
|
||||
empty: AddrTypeToTypeIndexMap<WithAddrDataSource<EmptyAddrData>>,
|
||||
/// Reusable scratch space for the unique addresses touched by one block.
|
||||
block_addresses: Vec<BlockAddress>,
|
||||
}
|
||||
|
||||
impl Default for AddrCache {
|
||||
@@ -31,6 +68,7 @@ impl AddrCache {
|
||||
Self {
|
||||
funded: AddrTypeToTypeIndexMap::default(),
|
||||
empty: AddrTypeToTypeIndexMap::default(),
|
||||
block_addresses: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,13 +84,50 @@ impl AddrCache {
|
||||
.is_some_and(|m| m.contains_key(&type_index))
|
||||
}
|
||||
|
||||
/// Merge address data into funded cache.
|
||||
#[inline]
|
||||
pub(crate) fn merge_funded(
|
||||
/// Load each address touched by the block once.
|
||||
pub(crate) fn load_block_addresses(
|
||||
&mut self,
|
||||
data: AddrTypeToTypeIndexMap<WithAddrDataSource<FundedAddrData>>,
|
||||
addresses: impl Iterator<Item = (OutputType, TypeIndex)>,
|
||||
first_addr_indexes: &ByAddrType<TypeIndex>,
|
||||
vr: &VecsReaders,
|
||||
any_addr_indexes: &AnyAddrIndexesVecs,
|
||||
addrs_data: &AddrsDataVecs,
|
||||
) {
|
||||
self.funded.merge_mut(data);
|
||||
self.block_addresses.clear();
|
||||
for (addr_type, type_index) in addresses {
|
||||
if addr_type.is_addr() && !self.contains(addr_type, type_index) {
|
||||
self.block_addresses
|
||||
.push(BlockAddress::new(addr_type, type_index));
|
||||
}
|
||||
}
|
||||
self.block_addresses.sort_unstable();
|
||||
self.block_addresses.dedup();
|
||||
|
||||
for index in 0..self.block_addresses.len() {
|
||||
let address = self.block_addresses[index];
|
||||
let addr_type = address.addr_type();
|
||||
let type_index = address.type_index();
|
||||
let first = *first_addr_indexes.get(addr_type).unwrap();
|
||||
|
||||
let source = if first <= type_index {
|
||||
WithAddrDataSource::New(FundedAddrData::default())
|
||||
} else {
|
||||
let any_addr_index = vr.any_addr_index(any_addr_indexes, addr_type, type_index);
|
||||
|
||||
match any_addr_index.to_enum() {
|
||||
AnyAddrDataIndexEnum::Funded(funded_index) => {
|
||||
let funded_data = vr.funded_data(addrs_data, funded_index);
|
||||
WithAddrDataSource::FromFunded(funded_index, funded_data)
|
||||
}
|
||||
AnyAddrDataIndexEnum::Empty(empty_index) => {
|
||||
let empty_data = vr.empty_data(addrs_data, empty_index);
|
||||
WithAddrDataSource::FromEmpty(empty_index, empty_data.into())
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
self.funded.insert_for_type(addr_type, type_index, source);
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an AddrLookup view into this cache.
|
||||
@@ -86,41 +161,19 @@ impl AddrCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// Load address data from storage or create new.
|
||||
///
|
||||
/// Returns None if address is already in cache (funded or empty).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn load_uncached_addr_data(
|
||||
addr_type: OutputType,
|
||||
type_index: TypeIndex,
|
||||
first_addr_indexes: &ByAddrType<TypeIndex>,
|
||||
cache: &AddrCache,
|
||||
vr: &VecsReaders,
|
||||
any_addr_indexes: &AnyAddrIndexesVecs,
|
||||
addrs_data: &AddrsDataVecs,
|
||||
) -> Option<WithAddrDataSource<FundedAddrData>> {
|
||||
// Check if this is a new address (type_index >= first for this height)
|
||||
let first = *first_addr_indexes.get(addr_type).unwrap();
|
||||
if first <= type_index {
|
||||
return Some(WithAddrDataSource::New(FundedAddrData::default()));
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Skip if already in cache
|
||||
if cache.contains(addr_type, type_index) {
|
||||
return None;
|
||||
}
|
||||
#[test]
|
||||
fn block_address_round_trips_every_address_type() {
|
||||
for addr_type in OutputType::ADDR_TYPES {
|
||||
for type_index in [TypeIndex::from(0_u32), TypeIndex::from(u32::MAX)] {
|
||||
let address = BlockAddress::new(addr_type, type_index);
|
||||
|
||||
// Read from storage
|
||||
let any_addr_index = vr.any_addr_index(any_addr_indexes, addr_type, type_index);
|
||||
|
||||
Some(match any_addr_index.to_enum() {
|
||||
AnyAddrDataIndexEnum::Funded(funded_index) => {
|
||||
let funded_data = vr.funded_data(addrs_data, funded_index);
|
||||
WithAddrDataSource::FromFunded(funded_index, funded_data)
|
||||
assert_eq!(address.addr_type(), addr_type);
|
||||
assert_eq!(address.type_index(), type_index);
|
||||
}
|
||||
}
|
||||
AnyAddrDataIndexEnum::Empty(empty_index) => {
|
||||
let empty_data = vr.empty_data(addrs_data, empty_index);
|
||||
WithAddrDataSource::FromEmpty(empty_index, empty_data.into())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,18 @@
|
||||
use brk_cohort::ByAddrType;
|
||||
use brk_types::{FundedAddrData, Height, OutputType, Sats, TxIndex, TypeIndex};
|
||||
use rayon::prelude::*;
|
||||
use brk_types::{Height, OutputType, Sats, TxIndex, TypeIndex};
|
||||
use rustc_hash::FxHashMap;
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::distribution::{
|
||||
addr::{AddrTypeToTypeIndexMap, AddrsDataVecs, AnyAddrIndexesVecs},
|
||||
compute::VecsReaders,
|
||||
addr::{AddrTypeToTypeIndexMap, HeightToAddrTypeToVec},
|
||||
state::Transacted,
|
||||
};
|
||||
|
||||
use crate::distribution::addr::HeightToAddrTypeToVec;
|
||||
|
||||
use super::super::{
|
||||
cache::{AddrCache, load_uncached_addr_data},
|
||||
cohort::WithAddrDataSource,
|
||||
};
|
||||
|
||||
/// Result of processing inputs for a block.
|
||||
pub struct InputsResult {
|
||||
/// Map from UTXO creation height -> aggregated sent supply.
|
||||
pub height_to_sent: FxHashMap<Height, Transacted>,
|
||||
/// Per-height, per-address-type sent data: (type_index, value) for each address.
|
||||
pub sent_data: HeightToAddrTypeToVec<(TypeIndex, Sats)>,
|
||||
/// Address data looked up during processing, keyed by (addr_type, type_index).
|
||||
pub addr_data: AddrTypeToTypeIndexMap<WithAddrDataSource<FundedAddrData>>,
|
||||
/// Transaction indexes per address for tx_count tracking.
|
||||
pub tx_index_vecs: AddrTypeToTypeIndexMap<SmallVec<[TxIndex; 4]>>,
|
||||
}
|
||||
@@ -36,65 +24,21 @@ pub struct InputsResult {
|
||||
/// 2. Resolve outpoint to txout_index
|
||||
/// 3. Get the creation height from txout_index_to_height map
|
||||
/// 4. Read value and type from the referenced output (random access via mmap)
|
||||
/// 5. Look up address data if input references an address type
|
||||
/// 6. Accumulate into height_to_sent map
|
||||
/// 7. Track address-specific data for address cohort processing
|
||||
///
|
||||
/// Uses parallel reads followed by sequential accumulation to avoid
|
||||
/// expensive merge overhead from rayon's fold/reduce pattern.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// 5. Accumulate into height_to_sent map
|
||||
/// 6. Track address-specific data for address cohort processing
|
||||
pub(crate) fn process_inputs(
|
||||
input_count: usize,
|
||||
txin_index_to_tx_index: &[TxIndex],
|
||||
txin_index_to_value: &[Sats],
|
||||
txin_index_to_output_type: &[OutputType],
|
||||
txin_index_to_type_index: &[TypeIndex],
|
||||
txin_index_to_prev_height: &[Height],
|
||||
first_addr_indexes: &ByAddrType<TypeIndex>,
|
||||
cache: &AddrCache,
|
||||
vr: &VecsReaders,
|
||||
any_addr_indexes: &AnyAddrIndexesVecs,
|
||||
addrs_data: &AddrsDataVecs,
|
||||
) -> InputsResult {
|
||||
let map_fn = |local_idx: usize| {
|
||||
let tx_index = txin_index_to_tx_index[local_idx];
|
||||
let input_count = txin_index_to_value.len();
|
||||
debug_assert_eq!(txin_index_to_tx_index.len(), input_count);
|
||||
debug_assert_eq!(txin_index_to_output_type.len(), input_count);
|
||||
debug_assert_eq!(txin_index_to_type_index.len(), input_count);
|
||||
debug_assert_eq!(txin_index_to_prev_height.len(), input_count);
|
||||
|
||||
let prev_height = txin_index_to_prev_height[local_idx];
|
||||
let value = txin_index_to_value[local_idx];
|
||||
let input_type = txin_index_to_output_type[local_idx];
|
||||
|
||||
if input_type.is_not_addr() {
|
||||
return (prev_height, value, input_type, None);
|
||||
}
|
||||
|
||||
let type_index = txin_index_to_type_index[local_idx];
|
||||
|
||||
// Look up address data
|
||||
let addr_data_opt = load_uncached_addr_data(
|
||||
input_type,
|
||||
type_index,
|
||||
first_addr_indexes,
|
||||
cache,
|
||||
vr,
|
||||
any_addr_indexes,
|
||||
addrs_data,
|
||||
);
|
||||
|
||||
(
|
||||
prev_height,
|
||||
value,
|
||||
input_type,
|
||||
Some((type_index, tx_index, value, addr_data_opt)),
|
||||
)
|
||||
};
|
||||
|
||||
let items: Vec<_> = if input_count < 128 {
|
||||
(0..input_count).map(map_fn).collect()
|
||||
} else {
|
||||
(0..input_count).into_par_iter().map(map_fn).collect()
|
||||
};
|
||||
|
||||
// Phase 2: Sequential accumulation - no merge overhead
|
||||
// Estimate: unique heights bounded by block depth, addresses spread across ~8 types
|
||||
let estimated_unique_heights = (input_count / 4).max(16);
|
||||
let estimated_per_type = (input_count / 8).max(8);
|
||||
@@ -103,43 +47,41 @@ pub(crate) fn process_inputs(
|
||||
Default::default(),
|
||||
);
|
||||
let mut sent_data = HeightToAddrTypeToVec::with_capacity(estimated_unique_heights);
|
||||
let mut addr_data = AddrTypeToTypeIndexMap::<WithAddrDataSource<FundedAddrData>>::with_capacity(
|
||||
estimated_per_type,
|
||||
);
|
||||
let mut tx_index_vecs =
|
||||
AddrTypeToTypeIndexMap::<SmallVec<[TxIndex; 4]>>::with_capacity(estimated_per_type);
|
||||
|
||||
for (prev_height, value, output_type, addr_info) in items {
|
||||
for local_idx in 0..input_count {
|
||||
let prev_height = txin_index_to_prev_height[local_idx];
|
||||
let value = txin_index_to_value[local_idx];
|
||||
let output_type = txin_index_to_output_type[local_idx];
|
||||
|
||||
height_to_sent
|
||||
.entry(prev_height)
|
||||
.or_default()
|
||||
.iterate(value, output_type);
|
||||
|
||||
if let Some((type_index, tx_index, value, addr_data_opt)) = addr_info {
|
||||
sent_data
|
||||
.entry(prev_height)
|
||||
.or_default()
|
||||
.get_mut(output_type)
|
||||
.unwrap()
|
||||
.push((type_index, value));
|
||||
|
||||
if let Some(source) = addr_data_opt {
|
||||
addr_data.insert_for_type(output_type, type_index, source);
|
||||
}
|
||||
|
||||
tx_index_vecs
|
||||
.get_mut(output_type)
|
||||
.unwrap()
|
||||
.entry(type_index)
|
||||
.or_default()
|
||||
.push(tx_index);
|
||||
if output_type.is_not_addr() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let type_index = txin_index_to_type_index[local_idx];
|
||||
sent_data
|
||||
.entry(prev_height)
|
||||
.or_default()
|
||||
.get_mut(output_type)
|
||||
.unwrap()
|
||||
.push((type_index, value));
|
||||
tx_index_vecs
|
||||
.get_mut(output_type)
|
||||
.unwrap()
|
||||
.entry(type_index)
|
||||
.or_default()
|
||||
.push(txin_index_to_tx_index[local_idx]);
|
||||
}
|
||||
|
||||
InputsResult {
|
||||
height_to_sent,
|
||||
sent_data,
|
||||
addr_data,
|
||||
tx_index_vecs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,18 @@
|
||||
use brk_cohort::ByAddrType;
|
||||
use brk_types::{FundedAddrData, Sats, TxIndex, TypeIndex};
|
||||
use rayon::prelude::*;
|
||||
use brk_types::{Sats, TxIndex, TypeIndex};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::distribution::{
|
||||
addr::{AddrTypeToTypeIndexMap, AddrTypeToVec, AddrsDataVecs, AnyAddrIndexesVecs},
|
||||
compute::{TxOutData, VecsReaders},
|
||||
addr::{AddrTypeToTypeIndexMap, AddrTypeToVec},
|
||||
compute::TxOutData,
|
||||
state::Transacted,
|
||||
};
|
||||
|
||||
use super::super::{
|
||||
cache::{AddrCache, load_uncached_addr_data},
|
||||
cohort::WithAddrDataSource,
|
||||
};
|
||||
|
||||
/// Result of processing outputs for a block.
|
||||
pub struct OutputsResult {
|
||||
/// Aggregated supply transacted in this block.
|
||||
pub transacted: Transacted,
|
||||
/// Per-address-type received data: (type_index, value) for each address.
|
||||
pub received_data: AddrTypeToVec<(TypeIndex, Sats)>,
|
||||
/// Address data looked up during processing, keyed by (addr_type, type_index).
|
||||
pub addr_data: AddrTypeToTypeIndexMap<WithAddrDataSource<FundedAddrData>>,
|
||||
/// Transaction indexes per address for tx_count tracking.
|
||||
pub tx_index_vecs: AddrTypeToTypeIndexMap<SmallVec<[TxIndex; 4]>>,
|
||||
}
|
||||
@@ -31,92 +22,45 @@ pub struct OutputsResult {
|
||||
/// For each output:
|
||||
/// 1. Read pre-collected value, output type, and type_index
|
||||
/// 2. Accumulate into Transacted by type and amount
|
||||
/// 3. Look up address data if output is an address type
|
||||
/// 4. Track address-specific data for address cohort processing
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// 3. Track address-specific data for address cohort processing
|
||||
pub(crate) fn process_outputs(
|
||||
txout_index_to_tx_index: &[TxIndex],
|
||||
txout_data_vec: &[TxOutData],
|
||||
first_addr_indexes: &ByAddrType<TypeIndex>,
|
||||
cache: &AddrCache,
|
||||
vr: &VecsReaders,
|
||||
any_addr_indexes: &AnyAddrIndexesVecs,
|
||||
addrs_data: &AddrsDataVecs,
|
||||
) -> OutputsResult {
|
||||
let output_count = txout_data_vec.len();
|
||||
debug_assert_eq!(txout_index_to_tx_index.len(), output_count);
|
||||
|
||||
// Phase 1: Addr lookups (mmap reads) — parallel for large blocks, sequential for small
|
||||
let map_fn = |local_idx: usize| {
|
||||
let txout_data = &txout_data_vec[local_idx];
|
||||
let value = txout_data.value;
|
||||
let output_type = txout_data.output_type;
|
||||
|
||||
if output_type.is_not_addr() {
|
||||
return (value, output_type, None);
|
||||
}
|
||||
|
||||
let type_index = txout_data.type_index;
|
||||
let tx_index = txout_index_to_tx_index[local_idx];
|
||||
|
||||
let addr_data_opt = load_uncached_addr_data(
|
||||
output_type,
|
||||
type_index,
|
||||
first_addr_indexes,
|
||||
cache,
|
||||
vr,
|
||||
any_addr_indexes,
|
||||
addrs_data,
|
||||
);
|
||||
|
||||
(
|
||||
value,
|
||||
output_type,
|
||||
Some((type_index, tx_index, value, addr_data_opt)),
|
||||
)
|
||||
};
|
||||
|
||||
let items: Vec<_> = if output_count < 128 {
|
||||
(0..output_count).map(map_fn).collect()
|
||||
} else {
|
||||
(0..output_count).into_par_iter().map(map_fn).collect()
|
||||
};
|
||||
|
||||
// Phase 2: Sequential accumulation
|
||||
let estimated_per_type = (output_count / 8).max(8);
|
||||
let mut transacted = Transacted::default();
|
||||
let mut received_data = AddrTypeToVec::with_capacity(estimated_per_type);
|
||||
let mut addr_data = AddrTypeToTypeIndexMap::<WithAddrDataSource<FundedAddrData>>::with_capacity(
|
||||
estimated_per_type,
|
||||
);
|
||||
let mut tx_index_vecs =
|
||||
AddrTypeToTypeIndexMap::<SmallVec<[TxIndex; 4]>>::with_capacity(estimated_per_type);
|
||||
|
||||
for (value, output_type, addr_info) in items {
|
||||
for (local_idx, txout_data) in txout_data_vec.iter().enumerate() {
|
||||
let value = txout_data.value;
|
||||
let output_type = txout_data.output_type;
|
||||
transacted.iterate(value, output_type);
|
||||
|
||||
if let Some((type_index, tx_index, value, addr_data_opt)) = addr_info {
|
||||
received_data
|
||||
.get_mut(output_type)
|
||||
.unwrap()
|
||||
.push((type_index, value));
|
||||
|
||||
if let Some(source) = addr_data_opt {
|
||||
addr_data.insert_for_type(output_type, type_index, source);
|
||||
}
|
||||
|
||||
tx_index_vecs
|
||||
.get_mut(output_type)
|
||||
.unwrap()
|
||||
.entry(type_index)
|
||||
.or_default()
|
||||
.push(tx_index);
|
||||
if output_type.is_not_addr() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let type_index = txout_data.type_index;
|
||||
received_data
|
||||
.get_mut(output_type)
|
||||
.unwrap()
|
||||
.push((type_index, value));
|
||||
tx_index_vecs
|
||||
.get_mut(output_type)
|
||||
.unwrap()
|
||||
.entry(type_index)
|
||||
.or_default()
|
||||
.push(txout_index_to_tx_index[local_idx]);
|
||||
}
|
||||
|
||||
OutputsResult {
|
||||
transacted,
|
||||
received_data,
|
||||
addr_data,
|
||||
tx_index_vecs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,28 @@ impl UTXOCohorts {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Iterate over the current in-memory age-cohort URPD entries.
|
||||
///
|
||||
/// Prices use the same rounding as the persisted daily distributions, so
|
||||
/// consumers can avoid writing and immediately rereading the current day.
|
||||
pub(crate) fn age_range_urpd_entries(
|
||||
&self,
|
||||
) -> impl Iterator<Item = (usize, bool, CentsCompact, Sats)> + '_ {
|
||||
let sth_filter = &self.sth.metrics.filter;
|
||||
self.age_range
|
||||
.iter()
|
||||
.enumerate()
|
||||
.flat_map(move |(age, cohort)| {
|
||||
let is_sth = sth_filter.includes(cohort.filter());
|
||||
cohort.state.iter().flat_map(move |state| {
|
||||
state
|
||||
.cost_basis_map()
|
||||
.iter()
|
||||
.map(move |(&price, &sats)| (age, is_sth, rounded_urpd_price(price), sats))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Push all Fenwick-derived per-block results: percentiles, density, profitability.
|
||||
fn push_fenwick_results(&mut self, spot_price: Cents) {
|
||||
let (all_d, sth_d, lth_d) = self.caches.fenwick.density(spot_price);
|
||||
@@ -71,7 +93,7 @@ impl UTXOCohorts {
|
||||
};
|
||||
let mut merged: Vec<(CentsCompact, Sats)> = Vec::new();
|
||||
for (&price, &sats) in state.cost_basis_map().iter() {
|
||||
let rounded = price.round_to_dollar(COST_BASIS_PRICE_DIGITS);
|
||||
let rounded = rounded_urpd_price(price);
|
||||
if let Some(last) = merged.last_mut()
|
||||
&& last.0 == rounded
|
||||
{
|
||||
@@ -125,6 +147,11 @@ impl UTXOCohorts {
|
||||
}
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn rounded_urpd_price(price: CentsCompact) -> CentsCompact {
|
||||
price.round_to_dollar(COST_BASIS_PRICE_DIGITS)
|
||||
}
|
||||
|
||||
/// Push percentiles + density to cost basis vecs.
|
||||
#[inline(always)]
|
||||
fn push_cost_basis(
|
||||
@@ -198,9 +225,9 @@ impl MergeTarget {
|
||||
self.price_sats += amount;
|
||||
}
|
||||
|
||||
fn finalize_price(&mut self, price: Cents) {
|
||||
fn finalize_price(&mut self, price: CentsCompact) {
|
||||
if self.price_sats > 0 {
|
||||
let rounded: CentsCompact = price.round_to_dollar(COST_BASIS_PRICE_DIGITS).into();
|
||||
let rounded = rounded_urpd_price(price);
|
||||
if let Some((lp, ls)) = self.merged.last_mut()
|
||||
&& *lp == rounded
|
||||
{
|
||||
@@ -242,7 +269,7 @@ fn merge_k_way(
|
||||
if let Some(prev) = current_price
|
||||
&& prev != price
|
||||
{
|
||||
targets.for_each_mut(|t| t.finalize_price(prev.into()));
|
||||
targets.for_each_mut(|t| t.finalize_price(prev));
|
||||
}
|
||||
|
||||
current_price = Some(price);
|
||||
@@ -255,6 +282,6 @@ fn merge_k_way(
|
||||
}
|
||||
|
||||
if let Some(price) = current_price {
|
||||
targets.for_each_mut(|t| t.finalize_price(price.into()));
|
||||
targets.for_each_mut(|t| t.finalize_price(price));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ use crate::{
|
||||
distribution::{
|
||||
addr::AddrMetricsState,
|
||||
block::{
|
||||
AddrCache, InputsResult, TransferAddressCache, process_inputs, process_outputs,
|
||||
process_received, process_sent,
|
||||
AddrCache, TransferAddressCache, process_inputs, process_outputs, process_received,
|
||||
process_sent,
|
||||
},
|
||||
compute::write::{process_addr_updates, write},
|
||||
state::{BlockState, Transacted},
|
||||
@@ -259,17 +259,23 @@ pub(crate) fn process_blocks(
|
||||
|
||||
state.reset_per_block();
|
||||
|
||||
// Process outputs, inputs, and tick-tock in parallel via rayon::join.
|
||||
// Collection (build tx_index mappings + bulk mmap reads) is merged into the
|
||||
// processing closures so outputs and inputs collection overlap each other
|
||||
// and tick-tock, instead of running sequentially before the join.
|
||||
debug_assert!(input_count > 0);
|
||||
|
||||
// Keep tick-tock concurrent with the block reads and address processing.
|
||||
let (matured, (outputs_result, inputs_result)) = rayon::join(
|
||||
|| {
|
||||
vecs.utxo_cohorts
|
||||
.tick_tock_next_block(chain_state, timestamp)
|
||||
},
|
||||
|| {
|
||||
rayon::join(
|
||||
// Collect both sides concurrently, then load their shared addresses once.
|
||||
let (
|
||||
(txout_index_to_tx_index, txout_data_vec),
|
||||
(
|
||||
txin_index_to_tx_index,
|
||||
(input_values, input_prev_heights, input_output_types, input_type_indexes),
|
||||
),
|
||||
) = rayon::join(
|
||||
|| {
|
||||
let txout_index_to_tx_index = txout_to_tx_index_buf.build(
|
||||
first_tx_index,
|
||||
@@ -278,63 +284,54 @@ pub(crate) fn process_blocks(
|
||||
);
|
||||
let txout_data_vec =
|
||||
txout_iters.collect_block_outputs(first_txout_index, output_count);
|
||||
process_outputs(
|
||||
txout_index_to_tx_index,
|
||||
txout_data_vec,
|
||||
&first_addr_indexes,
|
||||
&cache,
|
||||
&vr,
|
||||
&vecs.any_addr_indexes,
|
||||
&vecs.addrs_data,
|
||||
)
|
||||
(txout_index_to_tx_index, txout_data_vec)
|
||||
},
|
||||
|| {
|
||||
if input_count > 1 {
|
||||
let txin_index_to_tx_index = txin_to_tx_index_buf.build(
|
||||
first_tx_index,
|
||||
tx_count,
|
||||
tx_index_to_input_count,
|
||||
);
|
||||
let (
|
||||
input_values,
|
||||
input_prev_heights,
|
||||
input_output_types,
|
||||
input_type_indexes,
|
||||
) = txin_iters.collect_block_inputs(
|
||||
first_txin_index + 1,
|
||||
input_count - 1,
|
||||
height,
|
||||
);
|
||||
process_inputs(
|
||||
input_count - 1,
|
||||
&txin_index_to_tx_index[1..],
|
||||
input_values,
|
||||
input_output_types,
|
||||
input_type_indexes,
|
||||
input_prev_heights,
|
||||
&first_addr_indexes,
|
||||
&cache,
|
||||
&vr,
|
||||
&vecs.any_addr_indexes,
|
||||
&vecs.addrs_data,
|
||||
)
|
||||
} else {
|
||||
InputsResult {
|
||||
height_to_sent: Default::default(),
|
||||
sent_data: Default::default(),
|
||||
addr_data: Default::default(),
|
||||
tx_index_vecs: Default::default(),
|
||||
}
|
||||
}
|
||||
let txin_index_to_tx_index = txin_to_tx_index_buf.build(
|
||||
first_tx_index,
|
||||
tx_count,
|
||||
tx_index_to_input_count,
|
||||
);
|
||||
let input_data = txin_iters.collect_block_inputs(
|
||||
first_txin_index + 1,
|
||||
input_count - 1,
|
||||
height,
|
||||
);
|
||||
(txin_index_to_tx_index, input_data)
|
||||
},
|
||||
);
|
||||
|
||||
cache.load_block_addresses(
|
||||
txout_data_vec
|
||||
.iter()
|
||||
.map(|data| (data.output_type, data.type_index))
|
||||
.chain(
|
||||
input_output_types
|
||||
.iter()
|
||||
.copied()
|
||||
.zip(input_type_indexes.iter().copied()),
|
||||
),
|
||||
&first_addr_indexes,
|
||||
&vr,
|
||||
&vecs.any_addr_indexes,
|
||||
&vecs.addrs_data,
|
||||
);
|
||||
|
||||
rayon::join(
|
||||
|| process_outputs(txout_index_to_tx_index, txout_data_vec),
|
||||
|| {
|
||||
process_inputs(
|
||||
&txin_index_to_tx_index[1..],
|
||||
input_values,
|
||||
input_output_types,
|
||||
input_type_indexes,
|
||||
input_prev_heights,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
// Merge new address data into current cache
|
||||
cache.merge_funded(outputs_result.addr_data);
|
||||
cache.merge_funded(inputs_result.addr_data);
|
||||
|
||||
// Combine tx_index_vecs from outputs and inputs, then update tx_count
|
||||
let combined_tx_index_vecs = outputs_result
|
||||
.tx_index_vecs
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
mod drawdown;
|
||||
mod fenwick;
|
||||
mod order_statistics;
|
||||
mod sliding_distribution;
|
||||
mod sliding_median;
|
||||
mod sliding_window;
|
||||
|
||||
pub(crate) use drawdown::*;
|
||||
pub(crate) use fenwick::*;
|
||||
pub(crate) use order_statistics::*;
|
||||
pub(crate) use sliding_distribution::*;
|
||||
pub(crate) use sliding_median::*;
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/// Exact order-statistics multiset backed by sqrt-decomposed sorted blocks.
|
||||
///
|
||||
/// Insert, remove, and rank lookup are O(sqrt(n)). Bulk construction sorts
|
||||
/// once, then partitions the values into sorted blocks.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ExactOrderStats {
|
||||
blocks: Vec<Vec<f64>>,
|
||||
len: usize,
|
||||
block_size: usize,
|
||||
}
|
||||
|
||||
impl ExactOrderStats {
|
||||
pub(crate) fn new(capacity: usize) -> Self {
|
||||
let block_size = ((capacity as f64).sqrt() as usize).max(64);
|
||||
Self {
|
||||
blocks: Vec::new(),
|
||||
len: 0,
|
||||
block_size,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_unsorted(mut values: Vec<f64>) -> Self {
|
||||
values.sort_unstable_by(f64::total_cmp);
|
||||
Self::from_sorted(values)
|
||||
}
|
||||
|
||||
pub(crate) fn from_sorted(values: Vec<f64>) -> Self {
|
||||
debug_assert!(
|
||||
values
|
||||
.windows(2)
|
||||
.all(|pair| !pair[0].total_cmp(&pair[1]).is_gt()),
|
||||
"order-statistics input must be sorted"
|
||||
);
|
||||
let mut stats = Self::new(values.len());
|
||||
stats.len = values.len();
|
||||
let mut values = values.into_iter();
|
||||
loop {
|
||||
let block: Vec<_> = values.by_ref().take(stats.block_size).collect();
|
||||
if block.is_empty() {
|
||||
break;
|
||||
}
|
||||
stats.blocks.push(block);
|
||||
}
|
||||
stats
|
||||
}
|
||||
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.len
|
||||
}
|
||||
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.len == 0
|
||||
}
|
||||
|
||||
pub(crate) fn insert(&mut self, value: f64) {
|
||||
self.len += 1;
|
||||
|
||||
if self.blocks.is_empty() {
|
||||
self.blocks.push(vec![value]);
|
||||
return;
|
||||
}
|
||||
|
||||
let block_index = self
|
||||
.blocks
|
||||
.partition_point(|block| {
|
||||
block
|
||||
.last()
|
||||
.is_some_and(|candidate| candidate.total_cmp(&value).is_lt())
|
||||
})
|
||||
.min(self.blocks.len() - 1);
|
||||
let block = &mut self.blocks[block_index];
|
||||
let position = block.partition_point(|candidate| candidate.total_cmp(&value).is_lt());
|
||||
block.insert(position, value);
|
||||
|
||||
if block.len() > 2 * self.block_size {
|
||||
let right = block.split_off(block.len() / 2);
|
||||
self.blocks.insert(block_index + 1, right);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn remove(&mut self, value: f64) -> bool {
|
||||
let block_index = self.blocks.partition_point(|block| {
|
||||
block
|
||||
.last()
|
||||
.is_some_and(|candidate| candidate.total_cmp(&value).is_lt())
|
||||
});
|
||||
let Some(block) = self.blocks.get_mut(block_index) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let position = block.partition_point(|candidate| candidate.total_cmp(&value).is_lt());
|
||||
if block
|
||||
.get(position)
|
||||
.is_none_or(|candidate| !candidate.total_cmp(&value).is_eq())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
block.remove(position);
|
||||
self.len -= 1;
|
||||
if block.is_empty() {
|
||||
self.blocks.remove(block_index);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn kth(&self, mut index: usize) -> f64 {
|
||||
for block in &self.blocks {
|
||||
if index < block.len() {
|
||||
return block[index];
|
||||
}
|
||||
index -= block.len();
|
||||
}
|
||||
unreachable!("order-statistics index out of bounds")
|
||||
}
|
||||
|
||||
/// Resolve sorted, unique ranks in one pass through the underlying blocks.
|
||||
pub(crate) fn values_at(&self, ranks: &[usize], out: &mut [f64]) {
|
||||
debug_assert_eq!(ranks.len(), out.len());
|
||||
debug_assert!(ranks.windows(2).all(|pair| pair[0] < pair[1]));
|
||||
debug_assert!(ranks.last().is_none_or(|&rank| rank < self.len));
|
||||
let mut rank_index = 0;
|
||||
let mut block_start = 0;
|
||||
for block in &self.blocks {
|
||||
let block_end = block_start + block.len();
|
||||
while rank_index < ranks.len() && ranks[rank_index] < block_end {
|
||||
out[rank_index] = block[ranks[rank_index] - block_start];
|
||||
rank_index += 1;
|
||||
}
|
||||
block_start += block.len();
|
||||
if rank_index == ranks.len() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
debug_assert_eq!(rank_index, ranks.len());
|
||||
}
|
||||
|
||||
pub(crate) fn count_lt(&self, value: f64) -> usize {
|
||||
let mut count = 0;
|
||||
for block in &self.blocks {
|
||||
if block
|
||||
.last()
|
||||
.is_some_and(|candidate| candidate.total_cmp(&value).is_lt())
|
||||
{
|
||||
count += block.len();
|
||||
continue;
|
||||
}
|
||||
count += block.partition_point(|candidate| candidate.total_cmp(&value).is_lt());
|
||||
break;
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
pub(crate) fn count_le(&self, value: f64) -> usize {
|
||||
let mut count = 0;
|
||||
for block in &self.blocks {
|
||||
if block
|
||||
.last()
|
||||
.is_some_and(|candidate| !candidate.total_cmp(&value).is_gt())
|
||||
{
|
||||
count += block.len();
|
||||
continue;
|
||||
}
|
||||
count += block.partition_point(|candidate| !candidate.total_cmp(&value).is_gt());
|
||||
break;
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
pub(crate) fn first(&self) -> f64 {
|
||||
self.blocks.first().unwrap().first().copied().unwrap()
|
||||
}
|
||||
|
||||
pub(crate) fn last(&self) -> f64 {
|
||||
self.blocks.last().unwrap().last().copied().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bulk_build_and_rank_queries_preserve_duplicates() {
|
||||
let stats = ExactOrderStats::from_unsorted(vec![3.0, 1.0, 2.0, 2.0]);
|
||||
|
||||
assert_eq!(stats.len(), 4);
|
||||
assert_eq!(stats.kth(0), 1.0);
|
||||
assert_eq!(stats.kth(1), 2.0);
|
||||
assert_eq!(stats.kth(2), 2.0);
|
||||
assert_eq!(stats.kth(3), 3.0);
|
||||
assert_eq!(stats.count_lt(2.0), 1);
|
||||
assert_eq!(stats.count_le(2.0), 3);
|
||||
let mut values = [0.0; 3];
|
||||
stats.values_at(&[0, 2, 3], &mut values);
|
||||
assert_eq!(values, [1.0, 2.0, 3.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_and_remove_update_order_statistics() {
|
||||
let mut stats = ExactOrderStats::new(4);
|
||||
for value in [3.0, 1.0, 2.0, 2.0] {
|
||||
stats.insert(value);
|
||||
}
|
||||
|
||||
assert!(stats.remove(2.0));
|
||||
assert!(!stats.remove(4.0));
|
||||
assert_eq!(stats.len(), 3);
|
||||
assert_eq!(stats.kth(0), 1.0);
|
||||
assert_eq!(stats.kth(1), 2.0);
|
||||
assert_eq!(stats.kth(2), 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn total_order_distinguishes_signed_zero() {
|
||||
let stats = ExactOrderStats::from_unsorted(vec![0.0, -0.0]);
|
||||
|
||||
assert_eq!(stats.kth(0).to_bits(), (-0.0_f64).to_bits());
|
||||
assert_eq!(stats.kth(1).to_bits(), 0.0_f64.to_bits());
|
||||
assert_eq!(stats.count_lt(0.0), 1);
|
||||
assert_eq!(stats.count_le(-0.0), 1);
|
||||
}
|
||||
}
|
||||
@@ -1,133 +1,18 @@
|
||||
use std::cmp::Ordering;
|
||||
|
||||
/// Sqrt-decomposed sorted structure for O(sqrt(n)) insert/remove/kth.
|
||||
///
|
||||
/// Maintains `blocks` sorted sub-arrays where each block is sorted and
|
||||
/// the blocks are ordered (max of block[i] <= min of block[i+1]).
|
||||
/// Total element count is tracked via `total_len`.
|
||||
struct SortedBlocks {
|
||||
blocks: Vec<Vec<f64>>,
|
||||
total_len: usize,
|
||||
block_size: usize,
|
||||
}
|
||||
|
||||
impl SortedBlocks {
|
||||
fn new(capacity: usize) -> Self {
|
||||
let block_size = ((capacity as f64).sqrt() as usize).max(64);
|
||||
Self {
|
||||
blocks: Vec::new(),
|
||||
total_len: 0,
|
||||
block_size,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build from a pre-sorted slice in O(n) by chunking directly into blocks.
|
||||
fn from_sorted(sorted: &[f64], block_size: usize) -> Self {
|
||||
let total_len = sorted.len();
|
||||
let blocks: Vec<Vec<f64>> = sorted.chunks(block_size).map(|c| c.to_vec()).collect();
|
||||
Self {
|
||||
blocks,
|
||||
total_len,
|
||||
block_size,
|
||||
}
|
||||
}
|
||||
|
||||
fn len(&self) -> usize {
|
||||
self.total_len
|
||||
}
|
||||
|
||||
fn is_empty(&self) -> bool {
|
||||
self.total_len == 0
|
||||
}
|
||||
|
||||
/// Insert a value in sorted order. O(sqrt(n)).
|
||||
fn insert(&mut self, value: f64) {
|
||||
self.total_len += 1;
|
||||
|
||||
if self.blocks.is_empty() {
|
||||
self.blocks.push(vec![value]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the block where value belongs: first block whose max >= value
|
||||
let block_idx = self
|
||||
.blocks
|
||||
.partition_point(|b| *b.last().unwrap() < value)
|
||||
.min(self.blocks.len() - 1);
|
||||
|
||||
let block = &mut self.blocks[block_idx];
|
||||
let pos = block.partition_point(|a| *a < value);
|
||||
block.insert(pos, value);
|
||||
|
||||
// Split if block too large
|
||||
if block.len() > 2 * self.block_size {
|
||||
let mid = block.len() / 2;
|
||||
let right = block[mid..].to_vec();
|
||||
block.truncate(mid);
|
||||
self.blocks.insert(block_idx + 1, right);
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove one occurrence of value. O(sqrt(n)).
|
||||
fn remove(&mut self, value: f64) -> bool {
|
||||
if self.blocks.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Binary search for first block whose max >= value
|
||||
let bi = self
|
||||
.blocks
|
||||
.partition_point(|b| b.last().is_some_and(|&last| last < value));
|
||||
if bi >= self.blocks.len() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let block = &mut self.blocks[bi];
|
||||
let pos = block.partition_point(|a| *a < value);
|
||||
if pos < block.len() && block[pos] == value {
|
||||
block.remove(pos);
|
||||
self.total_len -= 1;
|
||||
if block.is_empty() {
|
||||
self.blocks.remove(bi);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Get the k-th smallest element (0-indexed). O(sqrt(n)).
|
||||
fn kth(&self, mut k: usize) -> f64 {
|
||||
for block in &self.blocks {
|
||||
if k < block.len() {
|
||||
return block[k];
|
||||
}
|
||||
k -= block.len();
|
||||
}
|
||||
unreachable!("kth out of bounds")
|
||||
}
|
||||
|
||||
fn first(&self) -> f64 {
|
||||
self.blocks.first().unwrap().first().copied().unwrap()
|
||||
}
|
||||
|
||||
fn last(&self) -> f64 {
|
||||
self.blocks.last().unwrap().last().copied().unwrap()
|
||||
}
|
||||
}
|
||||
use super::order_statistics::ExactOrderStats;
|
||||
|
||||
/// Sorted sliding window for rolling distribution/median computations.
|
||||
///
|
||||
/// Uses sqrt-decomposition for O(sqrt(n)) insert/remove/kth instead of
|
||||
/// O(n) memmoves with a flat sorted Vec.
|
||||
pub(crate) struct SlidingWindowSorted {
|
||||
sorted: SortedBlocks,
|
||||
sorted: ExactOrderStats,
|
||||
prev_start: usize,
|
||||
}
|
||||
|
||||
impl SlidingWindowSorted {
|
||||
pub fn with_capacity(cap: usize) -> Self {
|
||||
Self {
|
||||
sorted: SortedBlocks::new(cap),
|
||||
sorted: ExactOrderStats::new(cap),
|
||||
prev_start: 0,
|
||||
}
|
||||
}
|
||||
@@ -140,9 +25,7 @@ impl SlidingWindowSorted {
|
||||
if slice.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut sorted_copy: Vec<f64> = slice.to_vec();
|
||||
sorted_copy.sort_unstable_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
|
||||
self.sorted = SortedBlocks::from_sorted(&sorted_copy, self.sorted.block_size);
|
||||
self.sorted = ExactOrderStats::from_unsorted(slice.to_vec());
|
||||
}
|
||||
|
||||
/// Add a new value and remove all expired values up to `new_start`.
|
||||
@@ -251,18 +134,7 @@ impl SlidingWindowSorted {
|
||||
// Single pass through blocks to get all values
|
||||
let ranks = &rank_set[..rank_count];
|
||||
let mut values = [0.0f64; 10];
|
||||
let mut ri = 0;
|
||||
let mut cumulative = 0;
|
||||
for block in &self.sorted.blocks {
|
||||
while ri < rank_count && ranks[ri] - cumulative < block.len() {
|
||||
values[ri] = block[ranks[ri] - cumulative];
|
||||
ri += 1;
|
||||
}
|
||||
cumulative += block.len();
|
||||
if ri >= rank_count {
|
||||
break;
|
||||
}
|
||||
}
|
||||
self.sorted.values_at(ranks, &mut values[..rank_count]);
|
||||
|
||||
// Interpolate results
|
||||
let mut out = [0.0; N];
|
||||
|
||||
@@ -336,7 +336,6 @@ impl Vecs {
|
||||
let needs_rebuild = !weighted_urpd_is_current || day_index >= recompute_from;
|
||||
if let Some(date) = indexes.day1.date.collect_one(day)
|
||||
&& (needs_rebuild || needs_evaluation)
|
||||
&& UrpdRaw::path(&distribution.states_path, UTXO_ALL_NAME.id, date).try_exists()?
|
||||
{
|
||||
let weights = mode_weights(
|
||||
day,
|
||||
@@ -346,17 +345,31 @@ impl Vecs {
|
||||
&coinflow_spending_rate,
|
||||
&bounds,
|
||||
);
|
||||
let urpds = build_day_urpds(&distribution.states_path, date, &weights)?;
|
||||
if needs_rebuild {
|
||||
write_weighted_day_urpds(
|
||||
&self.states_path,
|
||||
&weighted_urpd_names,
|
||||
date,
|
||||
&urpds,
|
||||
)?;
|
||||
}
|
||||
if needs_evaluation {
|
||||
evaluate_day(&urpds, &thresholds, &mut result);
|
||||
let urpds = if day_index + 1 == source_end {
|
||||
Some(build_current_day_urpds(
|
||||
&distribution.utxo_cohorts,
|
||||
&weights,
|
||||
))
|
||||
} else if UrpdRaw::path(&distribution.states_path, UTXO_ALL_NAME.id, date)
|
||||
.try_exists()?
|
||||
{
|
||||
Some(read_day_urpds(&distribution.states_path, date, &weights)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(urpds) = urpds {
|
||||
if needs_rebuild {
|
||||
write_weighted_day_urpds(
|
||||
&self.states_path,
|
||||
&weighted_urpd_names,
|
||||
date,
|
||||
&urpds,
|
||||
)?;
|
||||
}
|
||||
if needs_evaluation {
|
||||
evaluate_day(&urpds, &thresholds, &mut result);
|
||||
}
|
||||
}
|
||||
}
|
||||
calibration.observe(loss_shares);
|
||||
@@ -553,7 +566,7 @@ fn remove_urpd_dir(states_path: &Path, name: &str) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_day_urpds(
|
||||
fn read_day_urpds(
|
||||
distribution_states_path: &Path,
|
||||
date: Date,
|
||||
weights: &ModeWeights,
|
||||
@@ -575,23 +588,54 @@ fn build_day_urpds(
|
||||
};
|
||||
|
||||
for (price, sats) in source.map {
|
||||
let mass = u64::from(sats) as f64;
|
||||
let bucket = weighted.entry(price).or_default();
|
||||
for (mode, (all, weights)) in bucket.all.iter_mut().zip(&weights[1..]).enumerate() {
|
||||
if let Some(weights) = weights {
|
||||
let weighted_mass = mass * weights[age];
|
||||
*all += weighted_mass;
|
||||
if mode < STORED_WEIGHT_COUNT {
|
||||
bucket.terms[term][mode] += weighted_mass;
|
||||
}
|
||||
}
|
||||
}
|
||||
add_weighted_entry(&mut weighted, price, sats, age, term, weights);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(finalize_day_urpds(raw, weighted))
|
||||
}
|
||||
|
||||
fn build_current_day_urpds(utxos: &distribution::UTXOCohorts, weights: &ModeWeights) -> DayUrpds {
|
||||
build_urpds_from_age_entries(utxos.age_range_urpd_entries(), weights)
|
||||
}
|
||||
|
||||
fn build_urpds_from_age_entries(
|
||||
entries: impl IntoIterator<Item = (usize, bool, CentsCompact, Sats)>,
|
||||
weights: &ModeWeights,
|
||||
) -> DayUrpds {
|
||||
let mut raw = UrpdRaw::default();
|
||||
let mut weighted = WeightedUrpd::new();
|
||||
|
||||
for (age, is_sth, price, sats) in entries {
|
||||
*raw.map.entry(price).or_default() += sats;
|
||||
let term = if is_sth { STH_TERM } else { LTH_TERM };
|
||||
add_weighted_entry(&mut weighted, price, sats, age, term, weights);
|
||||
}
|
||||
|
||||
finalize_day_urpds(raw, weighted)
|
||||
}
|
||||
|
||||
fn add_weighted_entry(
|
||||
weighted: &mut WeightedUrpd,
|
||||
price: CentsCompact,
|
||||
sats: Sats,
|
||||
age: usize,
|
||||
term: usize,
|
||||
weights: &ModeWeights,
|
||||
) {
|
||||
let mass = u64::from(sats) as f64;
|
||||
let bucket = weighted.entry(price).or_default();
|
||||
for (mode, (all, weights)) in bucket.all.iter_mut().zip(&weights[1..]).enumerate() {
|
||||
if let Some(weights) = weights {
|
||||
let weighted_mass = mass * weights[age];
|
||||
*all += weighted_mass;
|
||||
if mode < STORED_WEIGHT_COUNT {
|
||||
bucket.terms[term][mode] += weighted_mass;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize_day_urpds(raw: UrpdRaw, weighted: WeightedUrpd) -> DayUrpds {
|
||||
let mut all: AllWeightedUrpds = std::array::from_fn(|_| UrpdRaw::default());
|
||||
let mut terms: TermWeightedUrpds =
|
||||
@@ -886,6 +930,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_day_entries_build_raw_and_weighted_urpds() {
|
||||
let weights: ModeWeights = std::array::from_fn(|_| Some([0.5; AGE_COHORT_COUNT]));
|
||||
let price = CentsCompact::new(100);
|
||||
let urpds = build_urpds_from_age_entries(
|
||||
[
|
||||
(0, true, price, Sats::from(3_u64)),
|
||||
(1, false, price, Sats::from(5_u64)),
|
||||
],
|
||||
&weights,
|
||||
);
|
||||
|
||||
assert_eq!(urpds.raw.map[&price], Sats::from(8_u64));
|
||||
assert_eq!(urpds.all[COINTIME_MODE - 1].map[&price], Sats::from(4_u64));
|
||||
assert_eq!(
|
||||
urpds.terms[STH_TERM][COINTIME_MODE - 1].map[&price],
|
||||
Sats::from(1_u64)
|
||||
);
|
||||
assert_eq!(
|
||||
urpds.terms[LTH_TERM][COINTIME_MODE - 1].map[&price],
|
||||
Sats::from(2_u64)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stores_only_cointime_and_coinflow_for_all_sth_and_lth() {
|
||||
let root =
|
||||
|
||||
@@ -12,7 +12,8 @@ use vecdb::{
|
||||
use crate::{
|
||||
indexes,
|
||||
internal::{
|
||||
NumericValue, PerBlock, PercentPerBlock, algo::FenwickTree,
|
||||
NumericValue, PerBlock, PercentPerBlock,
|
||||
algo::{ExactOrderStats, FenwickTree},
|
||||
db_utils::validate_any_computed_version_or_reset,
|
||||
},
|
||||
};
|
||||
@@ -20,30 +21,39 @@ use crate::{
|
||||
const VERSION: Version = Version::new(6);
|
||||
const MIN_HISTORY_BLOCKS: usize = 210_000;
|
||||
const WRITE_INTERVAL: usize = 10_000;
|
||||
/// Above this many missing outputs, coordinate compression is faster than
|
||||
/// applying exact live updates one at a time.
|
||||
const BULK_BACKFILL_THRESHOLD: usize = 10_000;
|
||||
const BANDS: [(f64, u8); 3] = [(0.00025, 3), (0.0005, 2), (0.001, 1)];
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum WindowKind {
|
||||
Full,
|
||||
Rolling(usize),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Config {
|
||||
upper_tail: bool,
|
||||
rolling: bool,
|
||||
window: WindowKind,
|
||||
positive_only: bool,
|
||||
}
|
||||
|
||||
const REALIZED: Config = Config {
|
||||
upper_tail: true,
|
||||
rolling: false,
|
||||
window: WindowKind::Full,
|
||||
positive_only: false,
|
||||
};
|
||||
|
||||
const COINS_IN_LOSS: Config = Config {
|
||||
upper_tail: true,
|
||||
rolling: false,
|
||||
window: WindowKind::Full,
|
||||
positive_only: true,
|
||||
};
|
||||
|
||||
const SELLER_EXHAUSTION: Config = Config {
|
||||
upper_tail: false,
|
||||
rolling: true,
|
||||
window: WindowKind::Rolling(MIN_HISTORY_BLOCKS),
|
||||
positive_only: true,
|
||||
};
|
||||
|
||||
@@ -61,6 +71,9 @@ where
|
||||
pub threshold_pct0_025: PerBlock<T, M>,
|
||||
pub tail: PercentPerBlock<PartsPerMillion32, M>,
|
||||
pub rank: PerBlock<StoredU8, M>,
|
||||
|
||||
#[traversable(skip)]
|
||||
history: LiveHistory,
|
||||
}
|
||||
|
||||
impl<T> Extreme<T>
|
||||
@@ -94,6 +107,7 @@ where
|
||||
)?,
|
||||
tail: PercentPerBlock::forced_import(db, &format!("{name}_tail"), version, indexes)?,
|
||||
rank: PerBlock::forced_import(db, &format!("{name}_rank"), version, indexes)?,
|
||||
history: LiveHistory::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -141,59 +155,138 @@ where
|
||||
self.tail.ppm.height.any_truncate_if_needed_at(start)?;
|
||||
self.rank.height.any_truncate_if_needed_at(start)?;
|
||||
|
||||
if source_end.saturating_sub(start) > BULK_BACKFILL_THRESHOLD {
|
||||
self.compute_bulk(source, start, source_end, config, exit)
|
||||
} else {
|
||||
self.compute_incremental(source, start, source_end, config, exit)
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_incremental(
|
||||
&mut self,
|
||||
source: &impl ReadableVec<Height, T>,
|
||||
start: usize,
|
||||
source_end: usize,
|
||||
config: Config,
|
||||
exit: &Exit,
|
||||
) -> Result<()> {
|
||||
let pending = if self.history.is_current(start, config.window) {
|
||||
source
|
||||
.collect_range_at(start, source_end)
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect()
|
||||
} else {
|
||||
let mut values: Vec<f64> = source
|
||||
.collect_range_at(0, source_end)
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect();
|
||||
let pending = values.split_off(start);
|
||||
self.history.rebuild(values, config);
|
||||
pending
|
||||
};
|
||||
|
||||
for (offset, value) in pending.into_iter().enumerate() {
|
||||
let height_index = start + offset;
|
||||
let state = if is_valid(value, config) && self.history.len() >= MIN_HISTORY_BLOCKS {
|
||||
event_state(value, &self.history, config)
|
||||
} else {
|
||||
EventState::missing()
|
||||
};
|
||||
|
||||
self.push_state(state);
|
||||
self.history.observe(value, config);
|
||||
self.write_if_needed(height_index, source_end, exit)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Preserve the coordinate-compressed Fenwick path for large backfills.
|
||||
fn compute_bulk(
|
||||
&mut self,
|
||||
source: &impl ReadableVec<Height, T>,
|
||||
start: usize,
|
||||
source_end: usize,
|
||||
config: Config,
|
||||
exit: &Exit,
|
||||
) -> Result<()> {
|
||||
let values: Vec<f64> = source
|
||||
.collect_range_at(0, source_end)
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect();
|
||||
let is_valid = |value: f64| value.is_finite() && (!config.positive_only || value > 0.0);
|
||||
let mut coordinates: Vec<f64> = values.iter().copied().filter(|&v| is_valid(v)).collect();
|
||||
coordinates.sort_unstable_by(f64::total_cmp);
|
||||
let mut sorted_values: Vec<f64> = values
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&value| is_valid(value, config))
|
||||
.collect();
|
||||
sorted_values.sort_unstable_by(f64::total_cmp);
|
||||
let mut coordinates = sorted_values.clone();
|
||||
coordinates.dedup_by(|a, b| a.total_cmp(b).is_eq());
|
||||
|
||||
let mut history = History::new(coordinates.len().max(1), config.rolling);
|
||||
let mut history = CoordinateHistory::new(&coordinates, config.window);
|
||||
for &value in &values[..start] {
|
||||
if is_valid(value) {
|
||||
history.add(bucket(&coordinates, value));
|
||||
if is_valid(value, config) {
|
||||
history.add(value);
|
||||
}
|
||||
}
|
||||
|
||||
for (height_index, &value) in values.iter().enumerate().skip(start) {
|
||||
let state = if is_valid(value) && history.len >= MIN_HISTORY_BLOCKS {
|
||||
event_state(value, &coordinates, &history, config)
|
||||
let state = if is_valid(value, config) && history.len() >= MIN_HISTORY_BLOCKS {
|
||||
event_state(value, &history, config)
|
||||
} else {
|
||||
EventState::missing()
|
||||
};
|
||||
|
||||
self.threshold_pct0_1
|
||||
.height
|
||||
.push(T::from(state.thresholds.pct0_1));
|
||||
self.threshold_pct0_05
|
||||
.height
|
||||
.push(T::from(state.thresholds.pct0_05));
|
||||
self.threshold_pct0_025
|
||||
.height
|
||||
.push(T::from(state.thresholds.pct0_025));
|
||||
self.tail
|
||||
.ppm
|
||||
.height
|
||||
.push(PartsPerMillion32::from(state.tail));
|
||||
self.rank.height.push(StoredU8::new(state.rank));
|
||||
|
||||
if is_valid(value) {
|
||||
history.add(bucket(&coordinates, value));
|
||||
}
|
||||
|
||||
if (height_index + 1).is_multiple_of(WRITE_INTERVAL) || height_index + 1 == source_end {
|
||||
let _lock = exit.lock();
|
||||
self.threshold_pct0_1.height.write()?;
|
||||
self.threshold_pct0_05.height.write()?;
|
||||
self.threshold_pct0_025.height.write()?;
|
||||
self.tail.ppm.height.write()?;
|
||||
self.rank.height.write()?;
|
||||
self.push_state(state);
|
||||
if is_valid(value, config) {
|
||||
history.add(value);
|
||||
}
|
||||
self.write_if_needed(height_index, source_end, exit)?;
|
||||
}
|
||||
|
||||
let (sorted_values, window) = match config.window {
|
||||
WindowKind::Full => (sorted_values, HistoryWindow::Full),
|
||||
WindowKind::Rolling(_) => history.rolling_snapshot(),
|
||||
};
|
||||
self.history
|
||||
.replace_from_sorted(source_end, sorted_values, window);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn push_state(&mut self, state: EventState) {
|
||||
self.threshold_pct0_1
|
||||
.height
|
||||
.push(T::from(state.thresholds.pct0_1));
|
||||
self.threshold_pct0_05
|
||||
.height
|
||||
.push(T::from(state.thresholds.pct0_05));
|
||||
self.threshold_pct0_025
|
||||
.height
|
||||
.push(T::from(state.thresholds.pct0_025));
|
||||
self.tail
|
||||
.ppm
|
||||
.height
|
||||
.push(PartsPerMillion32::from(state.tail));
|
||||
self.rank.height.push(StoredU8::new(state.rank));
|
||||
}
|
||||
|
||||
fn write_if_needed(
|
||||
&mut self,
|
||||
height_index: usize,
|
||||
source_end: usize,
|
||||
exit: &Exit,
|
||||
) -> Result<()> {
|
||||
if (height_index + 1).is_multiple_of(WRITE_INTERVAL) || height_index + 1 == source_end {
|
||||
let _lock = exit.lock();
|
||||
self.threshold_pct0_1.height.write()?;
|
||||
self.threshold_pct0_05.height.write()?;
|
||||
self.threshold_pct0_025.height.write()?;
|
||||
self.tail.ppm.height.write()?;
|
||||
self.rank.height.write()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -268,43 +361,178 @@ impl Extremes {
|
||||
}
|
||||
}
|
||||
|
||||
struct History {
|
||||
tree: FenwickTree<f64>,
|
||||
len: usize,
|
||||
rolling: Option<VecDeque<usize>>,
|
||||
#[derive(Clone)]
|
||||
enum HistoryWindow<T> {
|
||||
Full,
|
||||
Rolling { values: VecDeque<T>, limit: usize },
|
||||
}
|
||||
|
||||
impl History {
|
||||
fn new(size: usize, rolling: bool) -> Self {
|
||||
Self {
|
||||
tree: FenwickTree::new(size),
|
||||
len: 0,
|
||||
rolling: rolling.then(VecDeque::new),
|
||||
impl<T> HistoryWindow<T> {
|
||||
fn new(kind: WindowKind) -> Self {
|
||||
match kind {
|
||||
WindowKind::Full => Self::Full,
|
||||
WindowKind::Rolling(limit) => Self::Rolling {
|
||||
values: VecDeque::with_capacity(limit),
|
||||
limit,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&mut self, bucket: usize) {
|
||||
fn kind(&self) -> WindowKind {
|
||||
match self {
|
||||
Self::Full => WindowKind::Full,
|
||||
Self::Rolling { limit, .. } => WindowKind::Rolling(*limit),
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&mut self, value: T) -> Option<T> {
|
||||
let Self::Rolling { values, limit } = self else {
|
||||
return None;
|
||||
};
|
||||
values.push_back(value);
|
||||
(values.len() > *limit).then(|| values.pop_front().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
struct CoordinateHistory<'a> {
|
||||
coordinates: &'a [f64],
|
||||
tree: FenwickTree<f64>,
|
||||
len: usize,
|
||||
window: HistoryWindow<usize>,
|
||||
}
|
||||
|
||||
impl<'a> CoordinateHistory<'a> {
|
||||
fn new(coordinates: &'a [f64], window: WindowKind) -> Self {
|
||||
Self {
|
||||
coordinates,
|
||||
tree: FenwickTree::new(coordinates.len().max(1)),
|
||||
len: 0,
|
||||
window: HistoryWindow::new(window),
|
||||
}
|
||||
}
|
||||
|
||||
fn add(&mut self, value: f64) {
|
||||
let bucket = bucket(self.coordinates, value);
|
||||
self.tree.add(bucket, &1.0);
|
||||
self.len += 1;
|
||||
|
||||
if let Some(rolling) = &mut self.rolling {
|
||||
rolling.push_back(bucket);
|
||||
if rolling.len() > MIN_HISTORY_BLOCKS {
|
||||
let expired = rolling.pop_front().unwrap();
|
||||
self.tree.add(expired, &-1.0);
|
||||
self.len -= 1;
|
||||
}
|
||||
if let Some(expired) = self.window.push(bucket) {
|
||||
self.tree.add(expired, &-1.0);
|
||||
self.len -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn quantile(&self, coordinates: &[f64], percentile: f64) -> f64 {
|
||||
fn rolling_snapshot(&self) -> (Vec<f64>, HistoryWindow<f64>) {
|
||||
let HistoryWindow::Rolling { values, limit } = &self.window else {
|
||||
unreachable!("rolling snapshot requested for full history")
|
||||
};
|
||||
|
||||
let mut counts = vec![0_usize; self.coordinates.len()];
|
||||
let chronological: VecDeque<_> = values
|
||||
.iter()
|
||||
.map(|&bucket| {
|
||||
counts[bucket] += 1;
|
||||
self.coordinates[bucket]
|
||||
})
|
||||
.collect();
|
||||
let sorted = self
|
||||
.coordinates
|
||||
.iter()
|
||||
.zip(counts)
|
||||
.flat_map(|(&value, count)| std::iter::repeat_n(value, count))
|
||||
.collect();
|
||||
|
||||
(
|
||||
sorted,
|
||||
HistoryWindow::Rolling {
|
||||
values: chronological,
|
||||
limit: *limit,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LiveHistory {
|
||||
stats: ExactOrderStats,
|
||||
processed: usize,
|
||||
window: HistoryWindow<f64>,
|
||||
}
|
||||
|
||||
impl LiveHistory {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
stats: ExactOrderStats::new(0),
|
||||
processed: 0,
|
||||
window: HistoryWindow::Full,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_current(&self, processed: usize, window: WindowKind) -> bool {
|
||||
self.processed == processed && self.window.kind() == window
|
||||
}
|
||||
|
||||
fn rebuild(&mut self, mut historical: Vec<f64>, config: Config) {
|
||||
self.processed = historical.len();
|
||||
historical.retain(|&value| is_valid(value, config));
|
||||
|
||||
self.window = HistoryWindow::new(config.window);
|
||||
if let HistoryWindow::Rolling { values, limit } = &mut self.window {
|
||||
let keep_from = historical.len().saturating_sub(*limit);
|
||||
historical.drain(..keep_from);
|
||||
values.extend(historical.iter().copied());
|
||||
}
|
||||
|
||||
self.stats = ExactOrderStats::from_unsorted(historical);
|
||||
}
|
||||
|
||||
fn replace_from_sorted(
|
||||
&mut self,
|
||||
processed: usize,
|
||||
sorted_values: Vec<f64>,
|
||||
window: HistoryWindow<f64>,
|
||||
) {
|
||||
if let HistoryWindow::Rolling { values, .. } = &window {
|
||||
debug_assert_eq!(sorted_values.len(), values.len());
|
||||
}
|
||||
self.processed = processed;
|
||||
self.stats = ExactOrderStats::from_sorted(sorted_values);
|
||||
self.window = window;
|
||||
}
|
||||
|
||||
fn observe(&mut self, value: f64, config: Config) {
|
||||
self.processed += 1;
|
||||
if !is_valid(value, config) {
|
||||
return;
|
||||
}
|
||||
|
||||
self.stats.insert(value);
|
||||
if let Some(expired) = self.window.push(value) {
|
||||
assert!(self.stats.remove(expired));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
trait HistoryStats {
|
||||
fn len(&self) -> usize;
|
||||
fn quantile(&self, percentile: f64) -> f64;
|
||||
fn tail(&self, value: f64, upper: bool) -> f64;
|
||||
}
|
||||
|
||||
impl HistoryStats for CoordinateHistory<'_> {
|
||||
fn len(&self) -> usize {
|
||||
self.len
|
||||
}
|
||||
|
||||
fn quantile(&self, percentile: f64) -> f64 {
|
||||
let target = ((self.len - 1) as f64 * percentile).floor();
|
||||
let mut index = [0];
|
||||
self.tree.kth(&[target], &|count: &f64| *count, &mut index);
|
||||
coordinates[index[0]]
|
||||
self.coordinates[index[0]]
|
||||
}
|
||||
|
||||
fn tail(&self, bucket: usize, upper: bool) -> f64 {
|
||||
fn tail(&self, value: f64, upper: bool) -> f64 {
|
||||
let bucket = bucket(self.coordinates, value);
|
||||
let less = if bucket == 0 {
|
||||
0.0
|
||||
} else {
|
||||
@@ -320,6 +548,26 @@ impl History {
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryStats for LiveHistory {
|
||||
fn len(&self) -> usize {
|
||||
self.stats.len()
|
||||
}
|
||||
|
||||
fn quantile(&self, percentile: f64) -> f64 {
|
||||
let index = ((self.stats.len() - 1) as f64 * percentile).floor() as usize;
|
||||
self.stats.kth(index)
|
||||
}
|
||||
|
||||
fn tail(&self, value: f64, upper: bool) -> f64 {
|
||||
let count = if upper {
|
||||
self.stats.len() - self.stats.count_lt(value)
|
||||
} else {
|
||||
self.stats.count_le(value)
|
||||
};
|
||||
(count as f64 + 1.0) / (self.stats.len() as f64 + 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
struct EventState {
|
||||
thresholds: EventThresholds,
|
||||
tail: f64,
|
||||
@@ -346,11 +594,11 @@ impl EventState {
|
||||
}
|
||||
}
|
||||
|
||||
fn event_state(value: f64, coordinates: &[f64], history: &History, config: Config) -> EventState {
|
||||
fn event_state(value: f64, history: &impl HistoryStats, config: Config) -> EventState {
|
||||
let percentile = |tail: f64| {
|
||||
if config.upper_tail { 1.0 - tail } else { tail }
|
||||
};
|
||||
let boundary = |tail: f64| history.quantile(coordinates, percentile(tail));
|
||||
let boundary = |tail: f64| history.quantile(percentile(tail));
|
||||
let thresholds = EventThresholds {
|
||||
pct0_1: boundary(BANDS[2].0),
|
||||
pct0_05: boundary(BANDS[1].0),
|
||||
@@ -374,11 +622,15 @@ fn event_state(value: f64, coordinates: &[f64], history: &History, config: Confi
|
||||
|
||||
EventState {
|
||||
thresholds,
|
||||
tail: history.tail(bucket(coordinates, value), config.upper_tail),
|
||||
tail: history.tail(value, config.upper_tail),
|
||||
rank,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid(value: f64, config: Config) -> bool {
|
||||
value.is_finite() && (!config.positive_only || value > 0.0)
|
||||
}
|
||||
|
||||
fn bucket(coordinates: &[f64], value: f64) -> usize {
|
||||
coordinates
|
||||
.binary_search_by(|candidate| candidate.total_cmp(&value))
|
||||
@@ -389,30 +641,25 @@ fn bucket(coordinates: &[f64], value: f64) -> usize {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn history(values: &[f64]) -> (Vec<f64>, History) {
|
||||
let mut coordinates = values.to_vec();
|
||||
coordinates.sort_unstable_by(f64::total_cmp);
|
||||
coordinates.dedup_by(|a, b| a.total_cmp(b).is_eq());
|
||||
let mut history = History::new(coordinates.len(), false);
|
||||
for &value in values {
|
||||
history.add(bucket(&coordinates, value));
|
||||
}
|
||||
(coordinates, history)
|
||||
fn history(values: &[f64], config: Config) -> LiveHistory {
|
||||
let mut history = LiveHistory::new();
|
||||
history.rebuild(values.to_vec(), config);
|
||||
history
|
||||
}
|
||||
|
||||
fn assert_same_state(left: EventState, right: EventState) {
|
||||
assert_eq!(left.thresholds.pct0_1, right.thresholds.pct0_1);
|
||||
assert_eq!(left.thresholds.pct0_05, right.thresholds.pct0_05);
|
||||
assert_eq!(left.thresholds.pct0_025, right.thresholds.pct0_025);
|
||||
assert_eq!(left.tail, right.tail);
|
||||
assert_eq!(left.rank, right.rank);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upper_tail_includes_current_observation() {
|
||||
let (mut coordinates, mut history) = history(&(1..=100).map(f64::from).collect::<Vec<_>>());
|
||||
coordinates.push(101.0);
|
||||
history.tree = {
|
||||
let mut tree = FenwickTree::new(coordinates.len());
|
||||
for value in 1..=100 {
|
||||
tree.add(bucket(&coordinates, f64::from(value)), &1.0);
|
||||
}
|
||||
tree
|
||||
};
|
||||
let history = history(&(1..=100).map(f64::from).collect::<Vec<_>>(), REALIZED);
|
||||
|
||||
let state = event_state(101.0, &coordinates, &history, REALIZED);
|
||||
let state = event_state(101.0, &history, REALIZED);
|
||||
assert!((state.tail - 1.0 / 101.0).abs() < f64::EPSILON);
|
||||
assert_eq!(state.rank, 3);
|
||||
assert_eq!(state.thresholds.pct0_025, 99.0);
|
||||
@@ -422,12 +669,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn lower_tail_includes_current_observation() {
|
||||
let coordinates: Vec<_> = (0..=100).map(f64::from).collect();
|
||||
let mut history = History::new(coordinates.len(), false);
|
||||
for value in 1..=100 {
|
||||
history.add(bucket(&coordinates, f64::from(value)));
|
||||
}
|
||||
let state = event_state(0.0, &coordinates, &history, SELLER_EXHAUSTION);
|
||||
let history = history(
|
||||
&(1..=100).map(f64::from).collect::<Vec<_>>(),
|
||||
SELLER_EXHAUSTION,
|
||||
);
|
||||
let state = event_state(0.0, &history, SELLER_EXHAUSTION);
|
||||
|
||||
assert!((state.tail - 1.0 / 101.0).abs() < f64::EPSILON);
|
||||
assert_eq!(state.rank, 3);
|
||||
@@ -435,4 +681,56 @@ mod tests {
|
||||
assert!(state.thresholds.pct0_1 >= state.thresholds.pct0_05);
|
||||
assert!(state.thresholds.pct0_05 >= state.thresholds.pct0_025);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incremental_history_matches_coordinate_history() {
|
||||
let values = [3.0, 1.0, 2.0, 2.0, -1.0, 5.0, 4.0, 5.0];
|
||||
let mut coordinates = values.to_vec();
|
||||
coordinates.sort_unstable_by(f64::total_cmp);
|
||||
coordinates.dedup_by(|a, b| a.total_cmp(b).is_eq());
|
||||
|
||||
let mut coordinate = CoordinateHistory::new(&coordinates, WindowKind::Full);
|
||||
let mut incremental = LiveHistory::new();
|
||||
|
||||
for value in values {
|
||||
if coordinate.len() > 0 {
|
||||
assert_same_state(
|
||||
event_state(value, &coordinate, REALIZED),
|
||||
event_state(value, &incremental, REALIZED),
|
||||
);
|
||||
}
|
||||
coordinate.add(value);
|
||||
incremental.observe(value, REALIZED);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rolling_history_evicts_the_oldest_observation() {
|
||||
let values: Vec<_> = (1..=MIN_HISTORY_BLOCKS + 1)
|
||||
.map(|value| value as f64)
|
||||
.collect();
|
||||
let mut history = history(&values, SELLER_EXHAUSTION);
|
||||
|
||||
assert_eq!(history.len(), MIN_HISTORY_BLOCKS);
|
||||
assert_eq!(history.stats.kth(0), 2.0);
|
||||
|
||||
history.observe((MIN_HISTORY_BLOCKS + 2) as f64, SELLER_EXHAUSTION);
|
||||
|
||||
assert_eq!(history.len(), MIN_HISTORY_BLOCKS);
|
||||
assert_eq!(history.stats.kth(0), 3.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rollback_rebuild_replaces_incremental_state() {
|
||||
let mut history = history(&[1.0, 2.0, 3.0], REALIZED);
|
||||
history.observe(4.0, REALIZED);
|
||||
assert!(history.is_current(4, WindowKind::Full));
|
||||
|
||||
history.rebuild(vec![1.0, 2.0], REALIZED);
|
||||
|
||||
assert!(history.is_current(2, WindowKind::Full));
|
||||
assert_eq!(history.len(), 2);
|
||||
assert_eq!(history.stats.kth(0), 1.0);
|
||||
assert_eq!(history.stats.kth(1), 2.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,15 +22,23 @@ const MAX_LOG_AGE_DAYS: u64 = 7;
|
||||
/// tracing level. The directory is created if it does not exist, and any
|
||||
/// `*.txt` file older than 7 days is pruned on startup.
|
||||
pub fn init(dir: Option<&Path>) -> io::Result<()> {
|
||||
tracing_log::LogTracer::init().ok();
|
||||
install_panic_hook();
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
const DEFAULT_LEVEL: &str = "debug";
|
||||
#[cfg(not(debug_assertions))]
|
||||
const DEFAULT_LEVEL: &str = "info";
|
||||
|
||||
let level = std::env::var("LOG").unwrap_or_else(|_| DEFAULT_LEVEL.to_string());
|
||||
init_with_default_level(dir, DEFAULT_LEVEL)
|
||||
}
|
||||
|
||||
/// Initialize the logger with a caller-selected fallback level.
|
||||
///
|
||||
/// `LOG` and `RUST_LOG` still take precedence. This is useful for services
|
||||
/// whose normal debug traffic is too verbose for their default execution mode.
|
||||
pub fn init_with_default_level(dir: Option<&Path>, default_level: &str) -> io::Result<()> {
|
||||
tracing_log::LogTracer::init().ok();
|
||||
install_panic_hook();
|
||||
|
||||
let level = std::env::var("LOG").unwrap_or_else(|_| default_level.to_string());
|
||||
|
||||
let directives = std::env::var("RUST_LOG").unwrap_or_else(|_| {
|
||||
format!(
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "brk_mcp"
|
||||
description = "A stateless MCP bridge to BRK's Cloudflare-cached REST API"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
homepage.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
axum = { workspace = true }
|
||||
base64 = "0.23.1"
|
||||
brk_logger = { workspace = true }
|
||||
rmcp = { version = "3.1.0", default-features = false, features = ["server", "transport-streamable-http-server"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "net", "sync", "time"] }
|
||||
tracing = { workspace = true }
|
||||
ureq = { workspace = true }
|
||||
|
||||
[[bin]]
|
||||
name = "brk_mcp"
|
||||
path = "src/main.rs"
|
||||
@@ -0,0 +1,54 @@
|
||||
# brk_mcp
|
||||
|
||||
`brk_mcp` is a thin, stateless, read-only MCP adapter for the BRK REST API. It
|
||||
exposes the generated OpenAPI operations as MCP tools and forwards every tool
|
||||
call to the configured public REST origin as a `GET` request.
|
||||
|
||||
## Caching model
|
||||
|
||||
`brk_mcp` does not cache API responses or retain MCP sessions. Point it at the
|
||||
public Cloudflare-fronted REST origin so its upstream `GET` requests use the
|
||||
existing Cloudflare cache:
|
||||
|
||||
```text
|
||||
MCP client -> brk_mcp -> Cloudflare-cached REST API -> BRK server
|
||||
```
|
||||
|
||||
Cloudflare does not need to cache the `/mcp` endpoint. The MCP catalog TTL is
|
||||
only a standard client-side cache hint for the static discovery and tool-list
|
||||
metadata.
|
||||
|
||||
## Run
|
||||
|
||||
From the workspace:
|
||||
|
||||
```sh
|
||||
cargo run --bin brk_mcp -- bitview.space
|
||||
```
|
||||
|
||||
Or run an installed binary:
|
||||
|
||||
```sh
|
||||
brk_mcp bitview.space
|
||||
```
|
||||
|
||||
A bare host tries HTTPS first and retries over HTTP only if the HTTPS request
|
||||
fails at the transport layer. An explicit origin uses only that origin:
|
||||
|
||||
```sh
|
||||
brk_mcp http://127.0.0.1:3110
|
||||
```
|
||||
|
||||
The Streamable HTTP endpoint is `http://127.0.0.1:3111/mcp` by default. If that
|
||||
port is unavailable, the server tries each port through `3211`. The server
|
||||
supports MCP protocol version `2026-07-28`.
|
||||
|
||||
The REST URL or host is the only configuration. It must be an HTTP(S) origin
|
||||
without a path, query, or credentials.
|
||||
|
||||
## Generated catalog
|
||||
|
||||
The tool catalog is generated from the canonical OpenAPI document by
|
||||
`brk_bindgen` and embedded in the binary at compile time from
|
||||
`generated/manifest.json`. Do not edit the generated manifest by hand; rerun
|
||||
the BRK bindgen target after changing the API.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
use axum::http::Uri;
|
||||
|
||||
pub fn api_bases(input: &str) -> Result<Vec<String>, String> {
|
||||
let input = input.trim().trim_end_matches('/');
|
||||
if input.is_empty() {
|
||||
return Err("REST API URL or host must not be empty".to_string());
|
||||
}
|
||||
let bases = if input.contains("://") {
|
||||
vec![input.to_string()]
|
||||
} else {
|
||||
vec![format!("https://{input}"), format!("http://{input}")]
|
||||
};
|
||||
for base in &bases {
|
||||
validate_api_base(base)?;
|
||||
}
|
||||
Ok(bases)
|
||||
}
|
||||
|
||||
fn validate_api_base(base: &str) -> Result<(), String> {
|
||||
let uri: Uri = base
|
||||
.parse()
|
||||
.map_err(|error| format!("invalid REST API origin: {error}"))?;
|
||||
if !matches!(uri.scheme_str(), Some("http" | "https")) || uri.authority().is_none() {
|
||||
return Err("REST API origin must be an absolute HTTP(S) URL".to_string());
|
||||
}
|
||||
if uri.query().is_some() || !matches!(uri.path(), "" | "/") {
|
||||
return Err("REST API origin must not contain a path or query".to_string());
|
||||
}
|
||||
if uri
|
||||
.authority()
|
||||
.is_some_and(|authority| authority.as_str().rsplit_once('@').is_some())
|
||||
{
|
||||
return Err("REST API origin must not contain credentials".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn accepts_origin_only_base_urls() {
|
||||
assert!(validate_api_base("https://bitview.space").is_ok());
|
||||
assert!(validate_api_base("http://127.0.0.1:3110").is_ok());
|
||||
assert!(validate_api_base("https://bitview.space/api").is_err());
|
||||
assert!(validate_api_base("https://user:pass@bitview.space").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expands_bare_hosts_with_https_first() {
|
||||
assert_eq!(
|
||||
api_bases("bitview.space").unwrap(),
|
||||
["https://bitview.space", "http://bitview.space"]
|
||||
);
|
||||
assert_eq!(
|
||||
api_bases("http://127.0.0.1:3110/").unwrap(),
|
||||
["http://127.0.0.1:3110"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_positional_origin_errors() {
|
||||
assert_eq!(
|
||||
validate_api_base("bitview.space").unwrap_err(),
|
||||
"REST API origin must be an absolute HTTP(S) URL"
|
||||
);
|
||||
assert_eq!(
|
||||
validate_api_base("https://bitview.space/api").unwrap_err(),
|
||||
"REST API origin must not contain a path or query"
|
||||
);
|
||||
assert_eq!(
|
||||
validate_api_base("https://user:pass@bitview.space").unwrap_err(),
|
||||
"REST API origin must not contain credentials"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
mod config;
|
||||
mod manifest;
|
||||
mod server;
|
||||
mod upstream;
|
||||
|
||||
use std::{env, error::Error, io};
|
||||
|
||||
use config::api_bases;
|
||||
use manifest::Catalog;
|
||||
use tokio::net::TcpListener;
|
||||
use tracing::info;
|
||||
|
||||
const BIND_START: std::net::SocketAddr =
|
||||
std::net::SocketAddr::new(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 3111);
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn Error>> {
|
||||
brk_logger::init_with_default_level(None, "info")?;
|
||||
|
||||
let mut arguments = env::args();
|
||||
let _program = arguments.next();
|
||||
let api_base = arguments
|
||||
.next()
|
||||
.ok_or_else(|| io::Error::other("usage: brk_mcp <REST_API_URL_OR_HOST>"))?;
|
||||
if arguments.next().is_some() {
|
||||
return Err(io::Error::other("usage: brk_mcp <REST_API_URL_OR_HOST>").into());
|
||||
}
|
||||
let api_bases = api_bases(&api_base).map_err(io::Error::other)?;
|
||||
let catalog = Catalog::embedded().map_err(io::Error::other)?;
|
||||
let tool_count = catalog.tools().len();
|
||||
let app = server::router(api_bases, catalog);
|
||||
let (listener, bind) = bind_available(BIND_START).await?;
|
||||
|
||||
info!(%bind, tool_count, "Starting stateless BRK MCP server");
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn bind_available(
|
||||
start: std::net::SocketAddr,
|
||||
) -> io::Result<(TcpListener, std::net::SocketAddr)> {
|
||||
let last_port = start.port().saturating_add(100);
|
||||
let mut last_error = None;
|
||||
for port in start.port()..=last_port {
|
||||
let mut candidate = start;
|
||||
candidate.set_port(port);
|
||||
match TcpListener::bind(candidate).await {
|
||||
Ok(listener) => return Ok((listener, candidate)),
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
}
|
||||
Err(last_error.unwrap_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::AddrNotAvailable,
|
||||
"no MCP bind port available",
|
||||
)
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,550 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use rmcp::model::Tool;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
const MANIFEST_JSON: &str = include_str!("../generated/manifest.json");
|
||||
const MAX_SCHEMA_DEPTH: usize = 32;
|
||||
const MAX_PARAMETER_BYTES: usize = 8 * 1024;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Manifest {
|
||||
schema_version: u32,
|
||||
operations: Vec<Operation>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Operation {
|
||||
pub tool: Tool,
|
||||
pub http: HttpOperation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct HttpOperation {
|
||||
pub method: String,
|
||||
pub path: String,
|
||||
pub parameters: Vec<Parameter>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Parameter {
|
||||
pub name: String,
|
||||
pub location: ParameterLocation,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ParameterLocation {
|
||||
Path,
|
||||
Query,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Catalog {
|
||||
tools: Vec<Tool>,
|
||||
operations: BTreeMap<String, Operation>,
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
pub fn embedded() -> Result<Self, String> {
|
||||
Self::parse(MANIFEST_JSON)
|
||||
}
|
||||
|
||||
fn parse(source: &str) -> Result<Self, String> {
|
||||
let manifest: Manifest = serde_json::from_str(source)
|
||||
.map_err(|error| format!("invalid generated LLM manifest: {error}"))?;
|
||||
if manifest.schema_version != 1 {
|
||||
return Err(format!(
|
||||
"unsupported generated LLM manifest version {}",
|
||||
manifest.schema_version
|
||||
));
|
||||
}
|
||||
if manifest.operations.is_empty() {
|
||||
return Err(
|
||||
"generated LLM manifest is empty; run the BRK bindgen target first".to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut tools = Vec::with_capacity(manifest.operations.len());
|
||||
let mut operations = BTreeMap::new();
|
||||
for operation in manifest.operations {
|
||||
validate_operation(&operation)?;
|
||||
let name = operation.tool.name.to_string();
|
||||
tools.push(operation.tool.clone());
|
||||
if operations.insert(name.clone(), operation).is_some() {
|
||||
return Err(format!("duplicate generated tool name {name}"));
|
||||
}
|
||||
}
|
||||
tools.sort_unstable_by(|left, right| left.name.cmp(&right.name));
|
||||
|
||||
Ok(Self { tools, operations })
|
||||
}
|
||||
|
||||
pub fn tools(&self) -> &[Tool] {
|
||||
&self.tools
|
||||
}
|
||||
|
||||
pub fn operation(&self, name: &str) -> Option<&Operation> {
|
||||
self.operations.get(name)
|
||||
}
|
||||
}
|
||||
|
||||
impl Operation {
|
||||
pub fn validate_arguments<'a>(
|
||||
&'a self,
|
||||
arguments: &'a Map<String, Value>,
|
||||
) -> Result<&'a Map<String, Value>, String> {
|
||||
let root = Value::Object(self.tool.input_schema.as_ref().clone());
|
||||
let value = Value::Object(arguments.clone());
|
||||
validate_schema(&root, &root, &value, 0)
|
||||
.map_err(|error| format!("invalid arguments: {error}"))?;
|
||||
Ok(arguments)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_operation(operation: &Operation) -> Result<(), String> {
|
||||
if operation.http.method != "GET" {
|
||||
return Err(format!(
|
||||
"tool {} is not a read-only GET operation",
|
||||
operation.tool.name
|
||||
));
|
||||
}
|
||||
let path = &operation.http.path;
|
||||
if !path.starts_with('/')
|
||||
|| path.starts_with("//")
|
||||
|| path.contains('?')
|
||||
|| path.contains('#')
|
||||
|| path.split('/').any(|segment| segment == "..")
|
||||
{
|
||||
return Err(format!(
|
||||
"tool {} has an unsafe generated path",
|
||||
operation.tool.name
|
||||
));
|
||||
}
|
||||
|
||||
let root = operation.tool.input_schema.as_ref();
|
||||
if root.get("type").and_then(Value::as_str) != Some("object") {
|
||||
return Err(format!(
|
||||
"tool {} input schema must have object type",
|
||||
operation.tool.name
|
||||
));
|
||||
}
|
||||
let properties = root
|
||||
.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"tool {} input schema has no properties",
|
||||
operation.tool.name
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut names = BTreeSet::new();
|
||||
for parameter in &operation.http.parameters {
|
||||
if !names.insert(¶meter.name) {
|
||||
return Err(format!(
|
||||
"tool {} repeats parameter {}",
|
||||
operation.tool.name, parameter.name
|
||||
));
|
||||
}
|
||||
if !properties.contains_key(¶meter.name) {
|
||||
return Err(format!(
|
||||
"tool {} maps unknown parameter {}",
|
||||
operation.tool.name, parameter.name
|
||||
));
|
||||
}
|
||||
let placeholder = format!("{{{}}}", parameter.name);
|
||||
match parameter.location {
|
||||
ParameterLocation::Path if !path.contains(&placeholder) => {
|
||||
return Err(format!(
|
||||
"tool {} path omits placeholder {placeholder}",
|
||||
operation.tool.name
|
||||
));
|
||||
}
|
||||
ParameterLocation::Query if path.contains(&placeholder) => {
|
||||
return Err(format!(
|
||||
"tool {} query parameter {} is used in its path",
|
||||
operation.tool.name, parameter.name
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if properties.len() != names.len() {
|
||||
return Err(format!(
|
||||
"tool {} contains an unmapped input property",
|
||||
operation.tool.name
|
||||
));
|
||||
}
|
||||
if path.contains('{') || path.contains('}') {
|
||||
for segment in path.split('/') {
|
||||
if let Some(name) = segment
|
||||
.strip_prefix('{')
|
||||
.and_then(|value| value.strip_suffix('}'))
|
||||
&& !operation.http.parameters.iter().any(|parameter| {
|
||||
parameter.location == ParameterLocation::Path && parameter.name == name
|
||||
})
|
||||
{
|
||||
return Err(format!(
|
||||
"tool {} contains unmapped path placeholder {name}",
|
||||
operation.tool.name
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validate_schema_definition(
|
||||
&Value::Object(operation.tool.input_schema.as_ref().clone()),
|
||||
0,
|
||||
)
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"tool {} has invalid input schema: {error}",
|
||||
operation.tool.name
|
||||
)
|
||||
})?;
|
||||
if let Some(output_schema) = &operation.tool.output_schema {
|
||||
validate_schema_definition(&Value::Object(output_schema.as_ref().clone()), 0).map_err(
|
||||
|error| {
|
||||
format!(
|
||||
"tool {} has invalid output schema: {error}",
|
||||
operation.tool.name
|
||||
)
|
||||
},
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_schema_definition(schema: &Value, depth: usize) -> Result<(), String> {
|
||||
if depth > MAX_SCHEMA_DEPTH {
|
||||
return Err("schema exceeds the maximum nesting depth".to_string());
|
||||
}
|
||||
if schema.is_boolean() {
|
||||
return Ok(());
|
||||
}
|
||||
let object = schema
|
||||
.as_object()
|
||||
.ok_or_else(|| "schemas must be JSON objects".to_string())?;
|
||||
if let Some(reference) = object.get("$ref").and_then(Value::as_str)
|
||||
&& !reference.starts_with("#/$defs/")
|
||||
{
|
||||
return Err(format!("non-local schema reference {reference}"));
|
||||
}
|
||||
for key in ["properties", "$defs"] {
|
||||
if let Some(values) = object.get(key).and_then(Value::as_object) {
|
||||
for nested in values.values() {
|
||||
validate_schema_definition(nested, depth + 1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(items) = object.get("items") {
|
||||
validate_schema_definition(items, depth + 1)?;
|
||||
}
|
||||
for key in ["allOf", "anyOf", "oneOf"] {
|
||||
if let Some(values) = object.get(key).and_then(Value::as_array) {
|
||||
for nested in values {
|
||||
validate_schema_definition(nested, depth + 1)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(not) = object.get("not") {
|
||||
validate_schema_definition(not, depth + 1)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_schema(
|
||||
root: &Value,
|
||||
schema: &Value,
|
||||
value: &Value,
|
||||
depth: usize,
|
||||
) -> Result<(), String> {
|
||||
if depth > MAX_SCHEMA_DEPTH {
|
||||
return Err("value exceeds the maximum schema depth".to_string());
|
||||
}
|
||||
let object = schema
|
||||
.as_object()
|
||||
.ok_or_else(|| "encountered a non-object schema".to_string())?;
|
||||
|
||||
if let Some(reference) = object.get("$ref").and_then(Value::as_str) {
|
||||
let pointer = reference
|
||||
.strip_prefix('#')
|
||||
.ok_or_else(|| format!("external schema reference {reference} is forbidden"))?;
|
||||
let target = root
|
||||
.pointer(pointer)
|
||||
.ok_or_else(|| format!("missing schema reference {reference}"))?;
|
||||
validate_schema(root, target, value, depth + 1)?;
|
||||
}
|
||||
|
||||
if let Some(expected) = object.get("const")
|
||||
&& expected != value
|
||||
{
|
||||
return Err("value does not match const".to_string());
|
||||
}
|
||||
if let Some(values) = object.get("enum").and_then(Value::as_array)
|
||||
&& !values.contains(value)
|
||||
{
|
||||
return Err("value is not in the allowed enum".to_string());
|
||||
}
|
||||
|
||||
if let Some(schemas) = object.get("allOf").and_then(Value::as_array) {
|
||||
for nested in schemas {
|
||||
validate_schema(root, nested, value, depth + 1)?;
|
||||
}
|
||||
}
|
||||
if let Some(schemas) = object.get("anyOf").and_then(Value::as_array)
|
||||
&& !schemas
|
||||
.iter()
|
||||
.any(|nested| validate_schema(root, nested, value, depth + 1).is_ok())
|
||||
{
|
||||
return Err("value does not match any allowed schema".to_string());
|
||||
}
|
||||
if let Some(schemas) = object.get("oneOf").and_then(Value::as_array)
|
||||
&& schemas
|
||||
.iter()
|
||||
.filter(|nested| validate_schema(root, nested, value, depth + 1).is_ok())
|
||||
.count()
|
||||
!= 1
|
||||
{
|
||||
return Err("value does not match exactly one allowed schema".to_string());
|
||||
}
|
||||
if let Some(nested) = object.get("not")
|
||||
&& validate_schema(root, nested, value, depth + 1).is_ok()
|
||||
{
|
||||
return Err("value matches a forbidden schema".to_string());
|
||||
}
|
||||
|
||||
if let Some(expected) = object.get("type") {
|
||||
let valid = match expected {
|
||||
Value::String(expected) => matches_type(expected, value),
|
||||
Value::Array(expected) => expected
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.any(|expected| matches_type(expected, value)),
|
||||
_ => false,
|
||||
};
|
||||
if !valid {
|
||||
return Err(format!("expected type {expected}"));
|
||||
}
|
||||
}
|
||||
|
||||
match value {
|
||||
Value::String(value) => validate_string(object, value),
|
||||
Value::Number(value) => validate_number(object, value),
|
||||
Value::Array(value) => validate_array(root, object, value, depth),
|
||||
Value::Object(value) => validate_object(root, object, value, depth),
|
||||
Value::Null | Value::Bool(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_type(expected: &str, value: &Value) -> bool {
|
||||
match expected {
|
||||
"null" => value.is_null(),
|
||||
"boolean" => value.is_boolean(),
|
||||
"object" => value.is_object(),
|
||||
"array" => value.is_array(),
|
||||
"number" => value.is_number(),
|
||||
"integer" => value.as_i64().is_some() || value.as_u64().is_some(),
|
||||
"string" => value.is_string(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_string(schema: &Map<String, Value>, value: &str) -> Result<(), String> {
|
||||
let length = value.chars().count() as u64;
|
||||
if value.len() > MAX_PARAMETER_BYTES {
|
||||
return Err(format!(
|
||||
"string exceeds the {MAX_PARAMETER_BYTES}-byte parameter limit"
|
||||
));
|
||||
}
|
||||
if let Some(minimum) = schema.get("minLength").and_then(Value::as_u64)
|
||||
&& length < minimum
|
||||
{
|
||||
return Err(format!("string is shorter than {minimum} characters"));
|
||||
}
|
||||
if let Some(maximum) = schema.get("maxLength").and_then(Value::as_u64)
|
||||
&& length > maximum
|
||||
{
|
||||
return Err(format!("string is longer than {maximum} characters"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_number(schema: &Map<String, Value>, value: &serde_json::Number) -> Result<(), String> {
|
||||
let value = value
|
||||
.as_f64()
|
||||
.ok_or_else(|| "number cannot be represented safely".to_string())?;
|
||||
if !value.is_finite() {
|
||||
return Err("number must be finite".to_string());
|
||||
}
|
||||
if let Some(minimum) = schema.get("minimum").and_then(Value::as_f64)
|
||||
&& value < minimum
|
||||
{
|
||||
return Err(format!("number is less than {minimum}"));
|
||||
}
|
||||
if let Some(maximum) = schema.get("maximum").and_then(Value::as_f64)
|
||||
&& value > maximum
|
||||
{
|
||||
return Err(format!("number is greater than {maximum}"));
|
||||
}
|
||||
if let Some(minimum) = schema.get("exclusiveMinimum").and_then(Value::as_f64)
|
||||
&& value <= minimum
|
||||
{
|
||||
return Err(format!("number must be greater than {minimum}"));
|
||||
}
|
||||
if let Some(maximum) = schema.get("exclusiveMaximum").and_then(Value::as_f64)
|
||||
&& value >= maximum
|
||||
{
|
||||
return Err(format!("number must be less than {maximum}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_array(
|
||||
root: &Value,
|
||||
schema: &Map<String, Value>,
|
||||
values: &[Value],
|
||||
depth: usize,
|
||||
) -> Result<(), String> {
|
||||
if let Some(minimum) = schema.get("minItems").and_then(Value::as_u64)
|
||||
&& values.len() < minimum as usize
|
||||
{
|
||||
return Err(format!("array has fewer than {minimum} items"));
|
||||
}
|
||||
if let Some(maximum) = schema.get("maxItems").and_then(Value::as_u64)
|
||||
&& values.len() > maximum as usize
|
||||
{
|
||||
return Err(format!("array has more than {maximum} items"));
|
||||
}
|
||||
if schema
|
||||
.get("uniqueItems")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
for (index, value) in values.iter().enumerate() {
|
||||
if values[..index].contains(value) {
|
||||
return Err("array items must be unique".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(items) = schema.get("items") {
|
||||
for value in values {
|
||||
validate_schema(root, items, value, depth + 1)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_object(
|
||||
root: &Value,
|
||||
schema: &Map<String, Value>,
|
||||
values: &Map<String, Value>,
|
||||
depth: usize,
|
||||
) -> Result<(), String> {
|
||||
if let Some(required) = schema.get("required").and_then(Value::as_array) {
|
||||
for name in required.iter().filter_map(Value::as_str) {
|
||||
if !values.contains_key(name) {
|
||||
return Err(format!("missing required property {name}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let properties = schema
|
||||
.get("properties")
|
||||
.and_then(Value::as_object)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
for (name, value) in values {
|
||||
if let Some(property_schema) = properties.get(name) {
|
||||
validate_schema(root, property_schema, value, depth + 1)?;
|
||||
continue;
|
||||
}
|
||||
match schema.get("additionalProperties") {
|
||||
Some(Value::Bool(false)) => return Err(format!("unknown property {name}")),
|
||||
Some(additional @ Value::Object(_)) => {
|
||||
validate_schema(root, additional, value, depth + 1)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rmcp::model::ToolAnnotations;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn operation() -> Operation {
|
||||
let input_schema = json!({
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "integer", "minimum": 1 },
|
||||
"format": { "enum": ["json", "csv"] }
|
||||
},
|
||||
"required": ["id"],
|
||||
"additionalProperties": false
|
||||
})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone();
|
||||
let output_schema = json!({
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "array",
|
||||
"items": true
|
||||
})
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.clone();
|
||||
Operation {
|
||||
tool: Tool::new_with_raw("get_item", None, Arc::new(input_schema))
|
||||
.with_raw_output_schema(Arc::new(output_schema))
|
||||
.with_annotations(
|
||||
ToolAnnotations::new()
|
||||
.read_only(true)
|
||||
.destructive(false)
|
||||
.idempotent(true)
|
||||
.open_world(true),
|
||||
),
|
||||
http: HttpOperation {
|
||||
method: "GET".to_string(),
|
||||
path: "/api/items/{id}".to_string(),
|
||||
parameters: vec![
|
||||
Parameter {
|
||||
name: "id".to_string(),
|
||||
location: ParameterLocation::Path,
|
||||
},
|
||||
Parameter {
|
||||
name: "format".to_string(),
|
||||
location: ParameterLocation::Query,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_generated_operations_and_arguments() {
|
||||
let operation = operation();
|
||||
validate_operation(&operation).unwrap();
|
||||
operation
|
||||
.validate_arguments(json!({ "id": 1, "format": "json" }).as_object().unwrap())
|
||||
.unwrap();
|
||||
assert!(
|
||||
operation
|
||||
.validate_arguments(json!({ "id": 0 }).as_object().unwrap())
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
operation
|
||||
.validate_arguments(json!({ "id": 1, "other": true }).as_object().unwrap())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
sync::{Arc, Mutex},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use axum::{
|
||||
Router,
|
||||
body::Body,
|
||||
extract::State,
|
||||
http::{
|
||||
Request, StatusCode,
|
||||
header::{ORIGIN, RETRY_AFTER},
|
||||
},
|
||||
middleware::{self, Next},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use base64::{Engine as _, prelude::BASE64_STANDARD};
|
||||
use rmcp::{
|
||||
ErrorData as McpError, RoleServer, ServerHandler,
|
||||
model::{
|
||||
CacheScope, CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock,
|
||||
DiscoverResult, Implementation, ListToolsResult, MetaObject, PaginatedRequestParams,
|
||||
ProtocolVersion, ResourceContents, ServerCapabilities, ServerInfo, Tool,
|
||||
},
|
||||
service::RequestContext,
|
||||
transport::streamable_http_server::{
|
||||
StreamableHttpServerConfig, StreamableHttpService, session::never::NeverSessionManager,
|
||||
},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use tokio::sync::Semaphore;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::{
|
||||
manifest::Catalog,
|
||||
upstream::{Upstream, UpstreamResponse},
|
||||
};
|
||||
|
||||
const CACHE_META_KEY: &str = "space.bitview/upstreamCache";
|
||||
const MAX_REQUEST_BYTES: usize = 64 * 1024;
|
||||
const MAX_CONCURRENCY: usize = 16;
|
||||
const REQUESTS_PER_SECOND: u32 = 50;
|
||||
const CATALOG_TTL_MS: u64 = 3_600_000;
|
||||
const MAX_UPSTREAM_ERROR_BYTES: usize = 2_048;
|
||||
static SUPPORTED_PROTOCOL_VERSIONS: [ProtocolVersion; 1] = [ProtocolVersion::V_2026_07_28];
|
||||
|
||||
struct AppState {
|
||||
catalog: Catalog,
|
||||
upstream: Upstream,
|
||||
concurrency: Arc<Semaphore>,
|
||||
rate_limit: Mutex<RateLimit>,
|
||||
}
|
||||
|
||||
struct RateLimit {
|
||||
window_started: Instant,
|
||||
used: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct BrkMcp {
|
||||
state: Arc<AppState>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn new(api_bases: Vec<String>, catalog: Catalog) -> Self {
|
||||
let upstream = Upstream::new(api_bases);
|
||||
Self {
|
||||
concurrency: Arc::new(Semaphore::new(MAX_CONCURRENCY)),
|
||||
rate_limit: Mutex::new(RateLimit {
|
||||
window_started: Instant::now(),
|
||||
used: 0,
|
||||
}),
|
||||
catalog,
|
||||
upstream,
|
||||
}
|
||||
}
|
||||
|
||||
fn admit(&self) -> bool {
|
||||
let mut limit = self.rate_limit.lock().expect("rate limit mutex poisoned");
|
||||
if limit.window_started.elapsed().as_secs() >= 1 {
|
||||
limit.window_started = Instant::now();
|
||||
limit.used = 0;
|
||||
}
|
||||
if limit.used >= REQUESTS_PER_SECOND {
|
||||
return false;
|
||||
}
|
||||
limit.used += 1;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
pub fn router(api_bases: Vec<String>, catalog: Catalog) -> Router {
|
||||
let transport_config = StreamableHttpServerConfig::default()
|
||||
.disable_allowed_hosts()
|
||||
.with_sse_keep_alive(None)
|
||||
.with_sse_retry(None)
|
||||
.with_legacy_session_mode(false)
|
||||
.with_json_response(true)
|
||||
.with_max_request_body_bytes(MAX_REQUEST_BYTES)
|
||||
.with_stateless_protocol_metadata_required(true);
|
||||
|
||||
let state = Arc::new(AppState::new(api_bases, catalog));
|
||||
let handler = BrkMcp {
|
||||
state: state.clone(),
|
||||
};
|
||||
let service = StreamableHttpService::new(
|
||||
move || Ok(handler.clone()),
|
||||
Arc::new(NeverSessionManager::default()),
|
||||
transport_config,
|
||||
);
|
||||
|
||||
Router::new()
|
||||
.route_service("/mcp", service)
|
||||
.layer(middleware::from_fn_with_state(state, gateway_guard))
|
||||
}
|
||||
|
||||
impl BrkMcp {
|
||||
fn server_info(&self) -> ServerInfo {
|
||||
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
|
||||
.with_protocol_version(ProtocolVersion::V_2026_07_28)
|
||||
.with_server_info(Implementation::new("brk_mcp", env!("CARGO_PKG_VERSION")))
|
||||
.with_instructions(
|
||||
"Read-only Bitcoin analytics tools backed by BRK REST GET endpoints. \
|
||||
Calls use the configured public Cloudflare-cached API and never mutate BRK state. \
|
||||
Tool descriptions preserve the OpenAPI documentation and identify their underlying \
|
||||
REST operation. Use available list, search, and info operations to discover \
|
||||
identifiers before querying dynamic datasets. Binary responses are returned as \
|
||||
embedded MCP resources.",
|
||||
)
|
||||
}
|
||||
|
||||
fn tool_error(&self, message: impl Into<String>) -> CallToolResponse {
|
||||
CallToolResult::error(vec![ContentBlock::text(message)])
|
||||
.with_meta(Some(server_meta()))
|
||||
.into()
|
||||
}
|
||||
|
||||
fn render_upstream(
|
||||
&self,
|
||||
response: UpstreamResponse,
|
||||
has_output_schema: bool,
|
||||
) -> CallToolResponse {
|
||||
if !(200..300).contains(&response.status) {
|
||||
let detail = String::from_utf8_lossy(
|
||||
&response.body[..response.body.len().min(MAX_UPSTREAM_ERROR_BYTES)],
|
||||
);
|
||||
let detail = detail.trim();
|
||||
let message = if detail.is_empty() {
|
||||
format!("BRK API returned HTTP {}", response.status)
|
||||
} else {
|
||||
format!("BRK API returned HTTP {}: {detail}", response.status)
|
||||
};
|
||||
return self.tool_error(message);
|
||||
}
|
||||
|
||||
let content_type = response
|
||||
.content_type
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap_or("application/octet-stream")
|
||||
.trim()
|
||||
.to_ascii_lowercase();
|
||||
let meta = Some(upstream_meta(&response));
|
||||
|
||||
if content_type == "application/json" || content_type.ends_with("+json") {
|
||||
return match serde_json::from_slice::<Value>(&response.body) {
|
||||
Ok(value) => CallToolResult::structured(value).with_meta(meta).into(),
|
||||
Err(_) => self.tool_error("BRK API returned invalid JSON"),
|
||||
};
|
||||
}
|
||||
|
||||
if content_type.starts_with("text/") {
|
||||
return match String::from_utf8(response.body) {
|
||||
Ok(text) => {
|
||||
let mut result =
|
||||
CallToolResult::success(vec![ContentBlock::text(text.clone())]);
|
||||
if has_output_schema {
|
||||
result.structured_content = Some(Value::String(text));
|
||||
}
|
||||
result.with_meta(meta).into()
|
||||
}
|
||||
Err(_) => self.tool_error("BRK API returned invalid UTF-8 text"),
|
||||
};
|
||||
}
|
||||
|
||||
let resource = ResourceContents::blob(BASE64_STANDARD.encode(response.body), response.url)
|
||||
.with_mime_type(content_type);
|
||||
CallToolResult::success(vec![ContentBlock::resource(resource)])
|
||||
.with_meta(meta)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl ServerHandler for BrkMcp {
|
||||
fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> {
|
||||
Cow::Borrowed(&SUPPORTED_PROTOCOL_VERSIONS)
|
||||
}
|
||||
|
||||
fn discover(
|
||||
&self,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> impl Future<Output = Result<DiscoverResult, McpError>> + Send + '_ {
|
||||
std::future::ready(Ok(DiscoverResult::from_server_info(
|
||||
SUPPORTED_PROTOCOL_VERSIONS.to_vec(),
|
||||
self.server_info(),
|
||||
)
|
||||
.with_ttl_ms(CATALOG_TTL_MS)
|
||||
.with_cache_scope(CacheScope::Public)))
|
||||
}
|
||||
|
||||
fn list_tools(
|
||||
&self,
|
||||
request: Option<PaginatedRequestParams>,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> impl Future<Output = Result<ListToolsResult, McpError>> + Send + '_ {
|
||||
let result = if request.and_then(|request| request.cursor).is_some() {
|
||||
Err(McpError::invalid_params(
|
||||
"This complete tool catalog does not accept a cursor",
|
||||
None,
|
||||
))
|
||||
} else {
|
||||
let mut result = ListToolsResult::with_all_items(self.state.catalog.tools().to_vec())
|
||||
.with_ttl_ms(CATALOG_TTL_MS)
|
||||
.with_cache_scope(CacheScope::Public);
|
||||
result.meta = Some(server_meta());
|
||||
Ok(result)
|
||||
};
|
||||
std::future::ready(result)
|
||||
}
|
||||
|
||||
fn get_tool(&self, name: &str) -> Option<Tool> {
|
||||
self.state
|
||||
.catalog
|
||||
.operation(name)
|
||||
.map(|operation| operation.tool.clone())
|
||||
}
|
||||
|
||||
async fn call_tool(
|
||||
&self,
|
||||
request: CallToolRequestParams,
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<CallToolResponse, McpError> {
|
||||
let name = request.name;
|
||||
let operation = self
|
||||
.state
|
||||
.catalog
|
||||
.operation(&name)
|
||||
.ok_or_else(|| McpError::invalid_params("Unknown tool name", None))?;
|
||||
let arguments = request.arguments.unwrap_or_default();
|
||||
let arguments = operation
|
||||
.validate_arguments(&arguments)
|
||||
.map_err(|error| McpError::invalid_params(error, None))?;
|
||||
let has_output_schema = operation.tool.output_schema.is_some();
|
||||
let prepared = self
|
||||
.state
|
||||
.upstream
|
||||
.prepare(operation, arguments)
|
||||
.map_err(|error| McpError::invalid_params(error, None))?;
|
||||
let permit = self
|
||||
.state
|
||||
.concurrency
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| McpError::internal_error("MCP proxy is at its concurrency limit", None))?;
|
||||
|
||||
let upstream = self.state.upstream.clone();
|
||||
let response = match tokio::task::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
upstream.fetch(prepared)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok(response)) => response,
|
||||
Ok(Err(error)) => return Ok(self.tool_error(error)),
|
||||
Err(_) => return Ok(self.tool_error("BRK API request task failed")),
|
||||
};
|
||||
if let Some(cache_status) = &response.cache_status {
|
||||
info!(
|
||||
tool = name.as_ref(),
|
||||
cache_status,
|
||||
cache_age = response.cache_age.as_deref().unwrap_or(""),
|
||||
"BRK API response"
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
tool = name.as_ref(),
|
||||
"BRK API response did not include CF-Cache-Status"
|
||||
);
|
||||
}
|
||||
Ok(self.render_upstream(response, has_output_schema))
|
||||
}
|
||||
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
self.server_info()
|
||||
}
|
||||
}
|
||||
|
||||
async fn gateway_guard(
|
||||
State(state): State<Arc<AppState>>,
|
||||
request: Request<Body>,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
if request.headers().contains_key(ORIGIN) {
|
||||
return (StatusCode::FORBIDDEN, "Forbidden origin").into_response();
|
||||
}
|
||||
|
||||
if !state.admit() {
|
||||
let mut response =
|
||||
(StatusCode::TOO_MANY_REQUESTS, "Request rate limit exceeded").into_response();
|
||||
response.headers_mut().insert(
|
||||
RETRY_AFTER,
|
||||
"1".parse().expect("static retry header is valid"),
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
next.run(request).await
|
||||
}
|
||||
|
||||
fn server_meta() -> MetaObject {
|
||||
let mut meta = MetaObject::new();
|
||||
meta.insert(
|
||||
"io.modelcontextprotocol/serverInfo".to_string(),
|
||||
json!({
|
||||
"name": "brk_mcp",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
}),
|
||||
);
|
||||
meta
|
||||
}
|
||||
|
||||
fn upstream_meta(response: &UpstreamResponse) -> MetaObject {
|
||||
let mut meta = server_meta();
|
||||
meta.insert(
|
||||
CACHE_META_KEY.to_string(),
|
||||
json!({
|
||||
"status": response.cache_status,
|
||||
"age": response.cache_age,
|
||||
}),
|
||||
);
|
||||
meta
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::manifest::{Operation, ParameterLocation};
|
||||
|
||||
const MAX_UPSTREAM_URL_BYTES: usize = 32 * 1024;
|
||||
const MAX_RESPONSE_BYTES: usize = 8 * 1024 * 1024;
|
||||
const UPSTREAM_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Upstream {
|
||||
agent: ureq::Agent,
|
||||
api_bases: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct PreparedRequest {
|
||||
path: String,
|
||||
query: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
pub struct UpstreamResponse {
|
||||
pub url: String,
|
||||
pub status: u16,
|
||||
pub content_type: String,
|
||||
pub body: Vec<u8>,
|
||||
pub cache_status: Option<String>,
|
||||
pub cache_age: Option<String>,
|
||||
}
|
||||
|
||||
impl Upstream {
|
||||
pub fn new(api_bases: Vec<String>) -> Self {
|
||||
let config = ureq::Agent::config_builder()
|
||||
.timeout_global(Some(UPSTREAM_TIMEOUT))
|
||||
.http_status_as_error(false)
|
||||
.max_redirects(0)
|
||||
.user_agent(format!("brk-mcp/{}", env!("CARGO_PKG_VERSION")))
|
||||
.build();
|
||||
Self {
|
||||
agent: ureq::Agent::new_with_config(config),
|
||||
api_bases,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prepare(
|
||||
&self,
|
||||
operation: &Operation,
|
||||
arguments: &Map<String, Value>,
|
||||
) -> Result<PreparedRequest, String> {
|
||||
let mut path = operation.http.path.clone();
|
||||
let mut query = Vec::new();
|
||||
|
||||
for parameter in &operation.http.parameters {
|
||||
let Some(value) = arguments.get(¶meter.name) else {
|
||||
continue;
|
||||
};
|
||||
if value.is_null() {
|
||||
continue;
|
||||
}
|
||||
let values = parameter_values(value)?;
|
||||
match parameter.location {
|
||||
ParameterLocation::Path => {
|
||||
if values.len() != 1 {
|
||||
return Err(format!(
|
||||
"path parameter {} must contain one scalar value",
|
||||
parameter.name
|
||||
));
|
||||
}
|
||||
path = path.replace(
|
||||
&format!("{{{}}}", parameter.name),
|
||||
&encode_path_segment(&values[0]),
|
||||
);
|
||||
}
|
||||
ParameterLocation::Query => {
|
||||
query.extend(
|
||||
values
|
||||
.into_iter()
|
||||
.map(|value| (parameter.name.clone(), value)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if path.contains('{') || path.contains('}') {
|
||||
return Err("a required path parameter is missing".to_string());
|
||||
}
|
||||
let query_bytes = query
|
||||
.iter()
|
||||
.map(|(name, value)| name.len() + value.len() + 2)
|
||||
.sum::<usize>();
|
||||
let longest_base = self.api_bases.iter().map(String::len).max().unwrap_or(0);
|
||||
if longest_base
|
||||
.saturating_add(path.len())
|
||||
.saturating_add(query_bytes)
|
||||
> MAX_UPSTREAM_URL_BYTES
|
||||
{
|
||||
return Err(format!(
|
||||
"upstream URL exceeds the {MAX_UPSTREAM_URL_BYTES}-byte limit"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(PreparedRequest { path, query })
|
||||
}
|
||||
|
||||
pub fn fetch(&self, request: PreparedRequest) -> Result<UpstreamResponse, String> {
|
||||
let PreparedRequest { path, query } = request;
|
||||
let mut last_error = None;
|
||||
for api_base in &self.api_bases {
|
||||
let url = format!("{api_base}{path}");
|
||||
let response = self
|
||||
.agent
|
||||
.get(&url)
|
||||
.query_pairs(query.iter().cloned())
|
||||
.header(
|
||||
"Accept",
|
||||
"application/json, text/plain, text/csv, application/octet-stream",
|
||||
)
|
||||
.call();
|
||||
match response {
|
||||
Ok(response) => return self.read_response(url, response),
|
||||
Err(error) => last_error = Some(error),
|
||||
}
|
||||
}
|
||||
Err(format!(
|
||||
"BRK API request failed: {}",
|
||||
last_error
|
||||
.map(|error| error.to_string())
|
||||
.unwrap_or_else(|| "no API base URL configured".to_string())
|
||||
))
|
||||
}
|
||||
|
||||
fn read_response(
|
||||
&self,
|
||||
url: String,
|
||||
mut response: ureq::http::Response<ureq::Body>,
|
||||
) -> Result<UpstreamResponse, String> {
|
||||
let status = response.status().as_u16();
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let cache_status = response
|
||||
.headers()
|
||||
.get("cf-cache-status")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let cache_age = response
|
||||
.headers()
|
||||
.get("age")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_string);
|
||||
let body = response
|
||||
.body_mut()
|
||||
.with_config()
|
||||
.limit(u64::try_from(MAX_RESPONSE_BYTES).unwrap_or(u64::MAX))
|
||||
.read_to_vec()
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"BRK API response could not be read within the {}-byte limit: {error}",
|
||||
MAX_RESPONSE_BYTES
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(UpstreamResponse {
|
||||
url,
|
||||
status,
|
||||
content_type,
|
||||
body,
|
||||
cache_status,
|
||||
cache_age,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn parameter_values(value: &Value) -> Result<Vec<String>, String> {
|
||||
match value {
|
||||
Value::String(value) => Ok(vec![value.clone()]),
|
||||
Value::Number(value) => Ok(vec![value.to_string()]),
|
||||
Value::Bool(value) => Ok(vec![value.to_string()]),
|
||||
Value::Array(values) => values
|
||||
.iter()
|
||||
.map(|value| match value {
|
||||
Value::String(value) => Ok(value.clone()),
|
||||
Value::Number(value) => Ok(value.to_string()),
|
||||
Value::Bool(value) => Ok(value.to_string()),
|
||||
_ => Err("URL parameter arrays may contain only scalar values".to_string()),
|
||||
})
|
||||
.collect(),
|
||||
Value::Null => Ok(Vec::new()),
|
||||
Value::Object(_) => Err("URL parameters may not be objects".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_path_segment(value: &str) -> String {
|
||||
const HEX: &[u8; 16] = b"0123456789ABCDEF";
|
||||
let mut encoded = String::with_capacity(value.len());
|
||||
for byte in value.bytes() {
|
||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
|
||||
encoded.push(char::from(byte));
|
||||
} else {
|
||||
encoded.push('%');
|
||||
encoded.push(char::from(HEX[(byte >> 4) as usize]));
|
||||
encoded.push(char::from(HEX[(byte & 0x0f) as usize]));
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{
|
||||
io::{Read, Write},
|
||||
net::TcpListener,
|
||||
thread,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn encodes_path_segments_without_leaving_separators() {
|
||||
assert_eq!(encode_path_segment("a/b c"), "a%2Fb%20c");
|
||||
assert_eq!(encode_path_segment("ż"), "%C5%BC");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_to_the_second_base_after_a_transport_failure() {
|
||||
let unavailable = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let unavailable_address = unavailable.local_addr().unwrap();
|
||||
drop(unavailable);
|
||||
|
||||
let fallback = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let fallback_address = fallback.local_addr().unwrap();
|
||||
let server = thread::spawn(move || {
|
||||
let (mut stream, _) = fallback.accept().unwrap();
|
||||
let mut request = [0_u8; 1024];
|
||||
let read = stream.read(&mut request).unwrap();
|
||||
assert!(String::from_utf8_lossy(&request[..read]).starts_with("GET /health HTTP/1.1"));
|
||||
stream
|
||||
.write_all(
|
||||
b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 11\r\nConnection: close\r\n\r\n{\"ok\":true}",
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
let upstream = Upstream::new(vec![
|
||||
format!("http://{unavailable_address}"),
|
||||
format!("http://{fallback_address}"),
|
||||
]);
|
||||
let response = upstream
|
||||
.fetch(PreparedRequest {
|
||||
path: "/health".to_string(),
|
||||
query: Vec::new(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
server.join().unwrap();
|
||||
assert_eq!(response.status, 200);
|
||||
assert_eq!(response.body, br#"{"ok":true}"#);
|
||||
assert_eq!(response.url, format!("http://{fallback_address}/health"));
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,7 @@ impl AddrRoutes for ApiRouter<AppState> {
|
||||
.id("get_address_hash_prefix_matches")
|
||||
.addrs_tag()
|
||||
.summary("Address hash-prefix matches")
|
||||
.description("Find addresses by address type and by the first 1-16 hex nibbles of RapidHash v3 over the raw address payload bytes. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata for the returned addresses through `/api/address/{address}`.")
|
||||
.description("Find addresses by address type and by the first 1-16 hex nibbles of RapidHash v3 over the raw address payload bytes. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata with `GET /api/address/{address}`.")
|
||||
.json_response::<AddrHashPrefixMatches>()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
@@ -97,7 +97,7 @@ impl AddrRoutes for ApiRouter<AppState> {
|
||||
.id("get_address_txs")
|
||||
.addrs_tag()
|
||||
.summary("Address transactions")
|
||||
.description("Get transaction history for an address, newest first. Returns up to 50 mempool transactions plus a confirmed page sized to fill the response to 50 total (chain floor of 25, so 25-50 confirmed depending on mempool weight). To paginate further confirmed history, use `/address/{address}/txs/chain/{last_seen_txid}`.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*")
|
||||
.description("Get transaction history for an address, newest first. Returns up to 50 mempool transactions plus a confirmed page sized to fill the response to 50 total (chain floor of 25, so 25-50 confirmed depending on mempool weight). To paginate further confirmed history, request `GET /api/address/{address}/txs/chain/{after_txid}` with the last returned txid.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*")
|
||||
.json_response::<Vec<Transaction>>()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
@@ -120,7 +120,7 @@ impl AddrRoutes for ApiRouter<AppState> {
|
||||
.id("get_address_confirmed_txs")
|
||||
.addrs_tag()
|
||||
.summary("Address confirmed transactions")
|
||||
.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)*")
|
||||
.description("Get the first 25 confirmed transactions for an address. For pagination, request `GET /api/address/{address}/txs/chain/{after_txid}` with the last returned txid.\n\n*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)*")
|
||||
.json_response::<Vec<Transaction>>()
|
||||
.not_modified()
|
||||
.bad_request()
|
||||
|
||||
@@ -131,7 +131,7 @@ impl MempoolRoutes for ApiRouter<AppState> {
|
||||
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)*")
|
||||
.description("Same response shape as `GET /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()
|
||||
@@ -152,7 +152,7 @@ impl MempoolRoutes for ApiRouter<AppState> {
|
||||
op.id("get_block_template")
|
||||
.mempool_tag()
|
||||
.summary("Projected next block template")
|
||||
.description("Bitcoin Core's `getblocktemplate` selection: full transaction bodies in GBT order with aggregate stats. The returned `hash` is an opaque content token; pass it as `<hash>` on `/api/v1/mempool/block-template/diff/{hash}` to fetch deltas instead of refetching the whole template.")
|
||||
.description("Bitcoin Core's `getblocktemplate` selection: full transaction bodies in GBT order with aggregate stats. The returned `hash` is an opaque content token; pass it to `GET /api/v1/mempool/block-template/diff/{hash}` to fetch deltas instead of refetching the whole template.")
|
||||
.json_response::<BlockTemplate>()
|
||||
.not_modified()
|
||||
.server_error()
|
||||
@@ -177,7 +177,7 @@ impl MempoolRoutes for ApiRouter<AppState> {
|
||||
op.id("get_block_template_diff")
|
||||
.mempool_tag()
|
||||
.summary("Block template diff since hash")
|
||||
.description("Delta of the projected next block since `<hash>`. `order` is the full new template in order: each entry is either a number (index into the prior template the client cached at `<hash>`) or a transaction object (new body to insert at this position). Walk `order` once to rebuild; `removed` is a convenience list of txids that left so clients can evict cached bodies. After applying, use the response `hash` as `<hash>` on the next call to keep iterating. Returns `404` when `<hash>` has aged out of server history; clients should fall back to `/api/v1/mempool/block-template`.")
|
||||
.description("Delta of the projected next block since `<hash>`. `order` is the full new template in order: each entry is either a number (index into the prior template the client cached at `<hash>`) or a transaction object (new body to insert at this position). Walk `order` once to rebuild; `removed` is a convenience list of txids that left so clients can evict cached bodies. After applying, use the response `hash` as `<hash>` on the next call to keep iterating. Returns `404` when `<hash>` has aged out of server history; clients should fall back to `GET /api/v1/mempool/block-template`.")
|
||||
.json_response::<BlockTemplateDiff>()
|
||||
.not_modified()
|
||||
.not_found()
|
||||
|
||||
@@ -92,7 +92,7 @@ impl ApiRoutes for ApiRouter<AppState> {
|
||||
.description(
|
||||
"Compact OpenAPI specification optimized for LLM consumption. \
|
||||
Removes redundant fields while preserving essential API information. \
|
||||
Full spec available at `/openapi.json`.",
|
||||
The full specification is available at `GET /openapi.json`.",
|
||||
)
|
||||
.json_response::<serde_json::Value>()
|
||||
},
|
||||
|
||||
@@ -187,7 +187,7 @@ All errors return structured JSON with a consistent format:
|
||||
or at any confirmed height: `raw` bins every output by value with no filtering, \
|
||||
while `ema` is the smoothed round-dollar window the price is read from. The live \
|
||||
price is also at `/api/mempool/price`. Confirmed per-height price history is at \
|
||||
`/api/vecs/height-to-price`."
|
||||
`/api/series/price/height`."
|
||||
.to_string(),
|
||||
),
|
||||
..Default::default()
|
||||
|
||||
@@ -33,8 +33,8 @@ impl OracleRoutes for ApiRouter<AppState> {
|
||||
.summary("Live BTC/USD price")
|
||||
.description(
|
||||
"Current BTC/USD price in dollars. Same value as \
|
||||
`/api/mempool/price`. Confirmed per-height history is available at \
|
||||
`/api/vecs/height-to-price`.",
|
||||
`GET /api/mempool/price`. Confirmed per-height history is available at \
|
||||
`GET /api/series/price/height`.",
|
||||
)
|
||||
.json_response::<Dollars>()
|
||||
.not_modified()
|
||||
|
||||
@@ -289,7 +289,8 @@ impl ApiSeriesRoutes for ApiRouter<AppState> {
|
||||
.summary("Get raw series data")
|
||||
.description(
|
||||
"Returns just the data array without the SeriesData wrapper. \
|
||||
Supports the same range and format parameters as the standard endpoint."
|
||||
Supports the same range and format parameters as \
|
||||
`GET /api/series/{series}/{index}`."
|
||||
)
|
||||
.json_response::<Vec<serde_json::Value>>()
|
||||
.csv_response()
|
||||
|
||||
@@ -51,7 +51,7 @@ impl ServerRoutes for ApiRouter<AppState> {
|
||||
op.id("get_health")
|
||||
.server_tag()
|
||||
.summary("Health check")
|
||||
.description("Liveness probe. Returns server identity, uptime, and indexed/computed heights from local state only (no bitcoind round-trip). For real chain-tip catch-up, see `/api/server/sync`.")
|
||||
.description("Liveness probe. Returns server identity, uptime, and indexed/computed heights from local state only (no bitcoind round-trip). For real chain-tip catch-up, request `GET /api/server/sync`.")
|
||||
.json_response::<Health>()
|
||||
},
|
||||
),
|
||||
|
||||
@@ -94,9 +94,11 @@ impl ApiUrpdRoutes for ApiRouter<AppState> {
|
||||
.summary("Latest URPD")
|
||||
.description(
|
||||
"URPD for the most recent available date in the cohort. \
|
||||
The response's `date` field echoes which date was served.\n\n\
|
||||
See the URPD tag description for the response shape, `agg`, and \
|
||||
`weight` options.",
|
||||
The response's `date` field echoes which date was served. Returns \
|
||||
`{ cohort, date, weight, aggregation, close, total_supply, buckets }`. \
|
||||
`close` and each bucket's `price_floor`, `realized_cap`, and \
|
||||
`unrealized_pnl` are USD; `total_supply` and bucket `supply` are BTC. \
|
||||
`unrealized_pnl` can be negative.",
|
||||
)
|
||||
.json_response::<Urpd>()
|
||||
.not_modified()
|
||||
@@ -132,9 +134,9 @@ impl ApiUrpdRoutes for ApiRouter<AppState> {
|
||||
.description(
|
||||
"URPD for a (cohort, date) pair. Returns \
|
||||
`{ cohort, date, weight, aggregation, close, total_supply, buckets }` where \
|
||||
each bucket is `{ price_floor, supply, realized_cap, unrealized_pnl }`.\n\n\
|
||||
See the URPD tag description for unit conventions, `agg`, and \
|
||||
`weight` options.",
|
||||
each bucket is `{ price_floor, supply, realized_cap, unrealized_pnl }`. \
|
||||
`close`, `price_floor`, `realized_cap`, and `unrealized_pnl` are USD; \
|
||||
`total_supply` and `supply` are BTC. `unrealized_pnl` can be negative.",
|
||||
)
|
||||
.json_response::<Urpd>()
|
||||
.not_modified()
|
||||
|
||||
@@ -335,7 +335,10 @@ pub fn generate_bindings(
|
||||
) -> std::io::Result<()> {
|
||||
let openapi_json = serde_json::to_string(openapi)
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
|
||||
brk_bindgen::generate_clients(vecs, &openapi_json, output_paths)
|
||||
let output_paths = output_paths.clone().llm_manifest(
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../brk_mcp/generated/manifest.json"),
|
||||
);
|
||||
brk_bindgen::generate_clients(vecs, &openapi_json, &output_paths)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -5,5 +5,7 @@ use serde::Deserialize;
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
pub struct AddrHashPrefixParam {
|
||||
pub addr_type: OutputType,
|
||||
/// First 1–16 hexadecimal nibbles of the RapidHash v3 hash over the raw
|
||||
/// address payload bytes.
|
||||
pub prefix: String,
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ use crate::Error;
|
||||
/// per-day variant, choosing the matching cache strategy.
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
pub struct HeightOrDateParam {
|
||||
/// Confirmed block height as decimal digits (`840000`) or calendar date in
|
||||
/// `YYYY-MM-DD` format.
|
||||
#[schemars(example = &"840000")]
|
||||
pub point: String,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ use serde::Deserialize;
|
||||
|
||||
use brk_types::NextBlockHash;
|
||||
|
||||
/// `since` hash for `/api/v1/mempool/block-template/diff/{hash}`.
|
||||
/// Prior-template hash for `GET /api/v1/mempool/block-template/diff/{hash}`.
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
pub struct NextBlockHashParam {
|
||||
pub hash: NextBlockHash,
|
||||
|
||||
@@ -7,6 +7,7 @@ use brk_types::{Cohort, Date, UrpdAggregation, UrpdWeight};
|
||||
#[derive(Deserialize, JsonSchema)]
|
||||
pub struct UrpdParams {
|
||||
pub cohort: Cohort,
|
||||
/// Calendar date of the URPD snapshot in `YYYY-MM-DD` format.
|
||||
#[schemars(with = "String", example = &"2024-01-01")]
|
||||
pub date: Date,
|
||||
}
|
||||
|
||||
@@ -9,8 +9,7 @@ use crate::{MempoolBlock, NextBlockHash, Transaction};
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BlockTemplate {
|
||||
/// Pass back as `<hash>` on
|
||||
/// `/api/v1/mempool/block-template/diff/{hash}` to fetch deltas.
|
||||
/// Pass to `GET /api/v1/mempool/block-template/diff/{hash}` to fetch deltas.
|
||||
pub hash: NextBlockHash,
|
||||
|
||||
/// Aggregate stats for this block (size, vsize, fee range, ...).
|
||||
|
||||
@@ -5,8 +5,7 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Content hash of the projected next block (block 0 of the mempool
|
||||
/// snapshot). Same value as the mempool ETag. Opaque token: pass back
|
||||
/// as `since` on `/api/v1/mempool/block-template/diff/{hash}` to fetch
|
||||
/// deltas.
|
||||
/// to `GET /api/v1/mempool/block-template/diff/{hash}` to fetch deltas.
|
||||
#[derive(
|
||||
Debug,
|
||||
Default,
|
||||
|
||||
+27
-28
@@ -40,7 +40,8 @@
|
||||
/**
|
||||
* @typedef {Object} AddrHashPrefixParam
|
||||
* @property {OutputType} addrType
|
||||
* @property {string} prefix
|
||||
* @property {string} prefix - First 1–16 hexadecimal nibbles of the RapidHash v3 hash over the raw
|
||||
address payload bytes.
|
||||
*/
|
||||
/**
|
||||
* Address statistics in the mempool (unconfirmed transactions only)
|
||||
@@ -271,8 +272,7 @@ Matches mempool.space/bitcoin-cli behavior.
|
||||
* `GET /api/v1/mempool/block-template`.
|
||||
*
|
||||
* @typedef {Object} BlockTemplate
|
||||
* @property {NextBlockHash} hash - Pass back as `<hash>` on
|
||||
`/api/v1/mempool/block-template/diff/{hash}` to fetch deltas.
|
||||
* @property {NextBlockHash} hash - Pass to `GET /api/v1/mempool/block-template/diff/{hash}` to fetch deltas.
|
||||
* @property {MempoolBlock} stats - Aggregate stats for this block (size, vsize, fee range, ...).
|
||||
* @property {Transaction[]} transactions - Full transaction bodies in `getblocktemplate` order.
|
||||
*/
|
||||
@@ -646,7 +646,8 @@ ancestors and no descendants (matches mempool.space).
|
||||
* per-day variant, choosing the matching cache strategy.
|
||||
*
|
||||
* @typedef {Object} HeightOrDateParam
|
||||
* @property {string} point
|
||||
* @property {string} point - Confirmed block height as decimal digits (`840000`) or calendar date in
|
||||
`YYYY-MM-DD` format.
|
||||
*/
|
||||
/**
|
||||
* Block height path parameter
|
||||
@@ -764,13 +765,12 @@ ancestors and no descendants (matches mempool.space).
|
||||
/**
|
||||
* Content hash of the projected next block (block 0 of the mempool
|
||||
* snapshot). Same value as the mempool ETag. Opaque token: pass back
|
||||
* as `since` on `/api/v1/mempool/block-template/diff/{hash}` to fetch
|
||||
* deltas.
|
||||
* to `GET /api/v1/mempool/block-template/diff/{hash}` to fetch deltas.
|
||||
*
|
||||
* @typedef {number} NextBlockHash
|
||||
*/
|
||||
/**
|
||||
* `since` hash for `/api/v1/mempool/block-template/diff/{hash}`.
|
||||
* Prior-template hash for `GET /api/v1/mempool/block-template/diff/{hash}`.
|
||||
*
|
||||
* @typedef {Object} NextBlockHashParam
|
||||
* @property {NextBlockHash} hash
|
||||
@@ -1410,7 +1410,7 @@ on serialization otherwise.
|
||||
*
|
||||
* @typedef {Object} UrpdParams
|
||||
* @property {Cohort} cohort
|
||||
* @property {string} date
|
||||
* @property {string} date - Calendar date of the URPD snapshot in `YYYY-MM-DD` format.
|
||||
*/
|
||||
/**
|
||||
* Query parameters for URPD endpoints.
|
||||
@@ -11470,7 +11470,7 @@ class BrkClient extends BrkClientBase {
|
||||
/**
|
||||
* Health check
|
||||
*
|
||||
* Liveness probe. Returns server identity, uptime, and indexed/computed heights from local state only (no bitcoind round-trip). For real chain-tip catch-up, see `/api/server/sync`.
|
||||
* Liveness probe. Returns server identity, uptime, and indexed/computed heights from local state only (no bitcoind round-trip). For real chain-tip catch-up, request `GET /api/server/sync`.
|
||||
*
|
||||
* Endpoint: `GET /health`
|
||||
* @param {{ signal?: AbortSignal, onValue?: (value: Health) => void, cache?: boolean, memCache?: boolean }} [options]
|
||||
@@ -11654,7 +11654,7 @@ class BrkClient extends BrkClientBase {
|
||||
/**
|
||||
* Get raw series data
|
||||
*
|
||||
* Returns just the data array without the SeriesData wrapper. Supports the same range and format parameters as the standard endpoint.
|
||||
* Returns just the data array without the SeriesData wrapper. Supports the same range and format parameters as `GET /api/series/{series}/{index}`.
|
||||
*
|
||||
* Endpoint: `GET /api/series/{series}/{index}/data`
|
||||
*
|
||||
@@ -11797,9 +11797,7 @@ class BrkClient extends BrkClientBase {
|
||||
/**
|
||||
* Latest URPD
|
||||
*
|
||||
* URPD for the most recent available date in the cohort. The response's `date` field echoes which date was served.
|
||||
*
|
||||
* See the URPD tag description for the response shape, `agg`, and `weight` options.
|
||||
* URPD for the most recent available date in the cohort. The response's `date` field echoes which date was served. Returns `{ cohort, date, weight, aggregation, close, total_supply, buckets }`. `close` and each bucket's `price_floor`, `realized_cap`, and `unrealized_pnl` are USD; `total_supply` and bucket `supply` are BTC. `unrealized_pnl` can be negative.
|
||||
*
|
||||
* Endpoint: `GET /api/urpd/{cohort}`
|
||||
*
|
||||
@@ -11821,14 +11819,12 @@ class BrkClient extends BrkClientBase {
|
||||
/**
|
||||
* URPD at date
|
||||
*
|
||||
* URPD for a (cohort, date) pair. Returns `{ cohort, date, weight, aggregation, close, total_supply, buckets }` where each bucket is `{ price_floor, supply, realized_cap, unrealized_pnl }`.
|
||||
*
|
||||
* See the URPD tag description for unit conventions, `agg`, and `weight` options.
|
||||
* URPD for a (cohort, date) pair. Returns `{ cohort, date, weight, aggregation, close, total_supply, buckets }` where each bucket is `{ price_floor, supply, realized_cap, unrealized_pnl }`. `close`, `price_floor`, `realized_cap`, and `unrealized_pnl` are USD; `total_supply` and `supply` are BTC. `unrealized_pnl` can be negative.
|
||||
*
|
||||
* Endpoint: `GET /api/urpd/{cohort}/{date}`
|
||||
*
|
||||
* @param {Cohort} cohort
|
||||
* @param {string} date
|
||||
* @param {string} date - Calendar date of the URPD snapshot in `YYYY-MM-DD` format.
|
||||
* @param {UrpdAggregation=} [agg] - Aggregation strategy. Default: raw (no aggregation). Accepts `bucket` as alias.
|
||||
* @param {UrpdWeight=} [weight] - Supply weighting. Default: raw (unweighted).
|
||||
* @param {{ signal?: AbortSignal, onValue?: (value: Urpd) => void, cache?: boolean, memCache?: boolean }} [options]
|
||||
@@ -11899,12 +11895,13 @@ class BrkClient extends BrkClientBase {
|
||||
/**
|
||||
* Address hash-prefix matches
|
||||
*
|
||||
* Find addresses by address type and by the first 1-16 hex nibbles of RapidHash v3 over the raw address payload bytes. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata for the returned addresses through `/api/address/{address}`.
|
||||
* Find addresses by address type and by the first 1-16 hex nibbles of RapidHash v3 over the raw address payload bytes. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata with `GET /api/address/{address}`.
|
||||
*
|
||||
* Endpoint: `GET /api/address/hash-prefix/{addr_type}/{prefix}`
|
||||
*
|
||||
* @param {OutputType} addr_type
|
||||
* @param {string} prefix
|
||||
* @param {string} prefix - First 1–16 hexadecimal nibbles of the RapidHash v3 hash over the raw
|
||||
address payload bytes.
|
||||
* @param {{ signal?: AbortSignal, onValue?: (value: AddrHashPrefixMatches) => void, cache?: boolean, memCache?: boolean }} [options]
|
||||
* @returns {Promise<AddrHashPrefixMatches>}
|
||||
*/
|
||||
@@ -11934,7 +11931,7 @@ class BrkClient extends BrkClientBase {
|
||||
/**
|
||||
* Address transactions
|
||||
*
|
||||
* Get transaction history for an address, newest first. Returns up to 50 mempool transactions plus a confirmed page sized to fill the response to 50 total (chain floor of 25, so 25-50 confirmed depending on mempool weight). To paginate further confirmed history, use `/address/{address}/txs/chain/{last_seen_txid}`.
|
||||
* Get transaction history for an address, newest first. Returns up to 50 mempool transactions plus a confirmed page sized to fill the response to 50 total (chain floor of 25, so 25-50 confirmed depending on mempool weight). To paginate further confirmed history, request `GET /api/address/{address}/txs/chain/{after_txid}` with the last returned txid.
|
||||
*
|
||||
* *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*
|
||||
*
|
||||
@@ -11952,7 +11949,7 @@ class BrkClient extends BrkClientBase {
|
||||
/**
|
||||
* Address confirmed transactions
|
||||
*
|
||||
* Get the first 25 confirmed transactions for an address. For pagination, use the path-style form `/txs/chain/{last_seen_txid}`.
|
||||
* Get the first 25 confirmed transactions for an address. For pagination, request `GET /api/address/{address}/txs/chain/{after_txid}` with the last returned txid.
|
||||
*
|
||||
* *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)*
|
||||
*
|
||||
@@ -12768,7 +12765,7 @@ class BrkClient extends BrkClientBase {
|
||||
/**
|
||||
* Recent full-RBF replacements
|
||||
*
|
||||
* Like `/api/v1/replacements`, but limited to trees where at least one predecessor was non-signaling (full-RBF).
|
||||
* Same response shape as `GET /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)*
|
||||
*
|
||||
@@ -12784,7 +12781,7 @@ class BrkClient extends BrkClientBase {
|
||||
/**
|
||||
* Projected next block template
|
||||
*
|
||||
* Bitcoin Core's `getblocktemplate` selection: full transaction bodies in GBT order with aggregate stats. The returned `hash` is an opaque content token; pass it as `<hash>` on `/api/v1/mempool/block-template/diff/{hash}` to fetch deltas instead of refetching the whole template.
|
||||
* Bitcoin Core's `getblocktemplate` selection: full transaction bodies in GBT order with aggregate stats. The returned `hash` is an opaque content token; pass it to `GET /api/v1/mempool/block-template/diff/{hash}` to fetch deltas instead of refetching the whole template.
|
||||
*
|
||||
* Endpoint: `GET /api/v1/mempool/block-template`
|
||||
* @param {{ signal?: AbortSignal, onValue?: (value: BlockTemplate) => void, cache?: boolean, memCache?: boolean }} [options]
|
||||
@@ -12798,7 +12795,7 @@ class BrkClient extends BrkClientBase {
|
||||
/**
|
||||
* Block template diff since hash
|
||||
*
|
||||
* Delta of the projected next block since `<hash>`. `order` is the full new template in order: each entry is either a number (index into the prior template the client cached at `<hash>`) or a transaction object (new body to insert at this position). Walk `order` once to rebuild; `removed` is a convenience list of txids that left so clients can evict cached bodies. After applying, use the response `hash` as `<hash>` on the next call to keep iterating. Returns `404` when `<hash>` has aged out of server history; clients should fall back to `/api/v1/mempool/block-template`.
|
||||
* Delta of the projected next block since `<hash>`. `order` is the full new template in order: each entry is either a number (index into the prior template the client cached at `<hash>`) or a transaction object (new body to insert at this position). Walk `order` once to rebuild; `removed` is a convenience list of txids that left so clients can evict cached bodies. After applying, use the response `hash` as `<hash>` on the next call to keep iterating. Returns `404` when `<hash>` has aged out of server history; clients should fall back to `GET /api/v1/mempool/block-template`.
|
||||
*
|
||||
* Endpoint: `GET /api/v1/mempool/block-template/diff/{hash}`
|
||||
*
|
||||
@@ -12828,7 +12825,7 @@ class BrkClient extends BrkClientBase {
|
||||
/**
|
||||
* Live BTC/USD price
|
||||
*
|
||||
* Current BTC/USD price in dollars. Same value as `/api/mempool/price`. Confirmed per-height history is available at `/api/vecs/height-to-price`.
|
||||
* Current BTC/USD price in dollars. Same value as `GET /api/mempool/price`. Confirmed per-height history is available at `GET /api/series/price/height`.
|
||||
*
|
||||
* Endpoint: `GET /api/oracle/price`
|
||||
* @param {{ signal?: AbortSignal, onValue?: (value: Dollars) => void, cache?: boolean, memCache?: boolean }} [options]
|
||||
@@ -12860,7 +12857,8 @@ class BrkClient extends BrkClientBase {
|
||||
*
|
||||
* Endpoint: `GET /api/oracle/histogram/payments/{point}`
|
||||
*
|
||||
* @param {string} point
|
||||
* @param {string} point - Confirmed block height as decimal digits (`840000`) or calendar date in
|
||||
`YYYY-MM-DD` format.
|
||||
* @param {{ signal?: AbortSignal, onValue?: (value: number[]) => void, cache?: boolean, memCache?: boolean }} [options]
|
||||
* @returns {Promise<number[]>}
|
||||
*/
|
||||
@@ -12890,7 +12888,8 @@ class BrkClient extends BrkClientBase {
|
||||
*
|
||||
* Endpoint: `GET /api/oracle/histogram/outputs/{point}`
|
||||
*
|
||||
* @param {string} point
|
||||
* @param {string} point - Confirmed block height as decimal digits (`840000`) or calendar date in
|
||||
`YYYY-MM-DD` format.
|
||||
* @param {{ signal?: AbortSignal, onValue?: (value: number[]) => void, cache?: boolean, memCache?: boolean }} [options]
|
||||
* @returns {Promise<number[]>}
|
||||
*/
|
||||
@@ -13152,7 +13151,7 @@ class BrkClient extends BrkClientBase {
|
||||
/**
|
||||
* Compact OpenAPI specification
|
||||
*
|
||||
* Compact OpenAPI specification optimized for LLM consumption. Removes redundant fields while preserving essential API information. Full spec available at `/openapi.json`.
|
||||
* Compact OpenAPI specification optimized for LLM consumption. Removes redundant fields while preserving essential API information. The full specification is available at `GET /openapi.json`.
|
||||
*
|
||||
* Endpoint: `GET /api.json`
|
||||
* @param {{ signal?: AbortSignal, onValue?: (value: *) => void, cache?: boolean, memCache?: boolean }} [options]
|
||||
|
||||
@@ -53,8 +53,7 @@ BlockHash = str
|
||||
BlockTxIndex = int
|
||||
# Content hash of the projected next block (block 0 of the mempool
|
||||
# snapshot). Same value as the mempool ETag. Opaque token: pass back
|
||||
# as `since` on `/api/v1/mempool/block-template/diff/{hash}` to fetch
|
||||
# deltas.
|
||||
# to `GET /api/v1/mempool/block-template/diff/{hash}` to fetch deltas.
|
||||
NextBlockHash = int
|
||||
# Transaction locktime. Values below 500,000,000 are interpreted as block heights; values at or above are Unix timestamps.
|
||||
RawLockTime = int
|
||||
@@ -317,6 +316,11 @@ class AddrHashPrefixMatches(TypedDict):
|
||||
addresses: List[Addr]
|
||||
|
||||
class AddrHashPrefixParam(TypedDict):
|
||||
"""
|
||||
Attributes:
|
||||
prefix: First 1–16 hexadecimal nibbles of the RapidHash v3 hash over the raw
|
||||
address payload bytes.
|
||||
"""
|
||||
addr_type: OutputType
|
||||
prefix: str
|
||||
|
||||
@@ -793,8 +797,7 @@ class BlockTemplate(TypedDict):
|
||||
`GET /api/v1/mempool/block-template`.
|
||||
|
||||
Attributes:
|
||||
hash: Pass back as `<hash>` on
|
||||
`/api/v1/mempool/block-template/diff/{hash}` to fetch deltas.
|
||||
hash: Pass to `GET /api/v1/mempool/block-template/diff/{hash}` to fetch deltas.
|
||||
stats: Aggregate stats for this block (size, vsize, fee range, ...).
|
||||
transactions: Full transaction bodies in `getblocktemplate` order.
|
||||
"""
|
||||
@@ -1165,6 +1168,10 @@ class HeightOrDateParam(TypedDict):
|
||||
Path parameter accepting either a block height (`840000`) or a calendar date
|
||||
(`YYYY-MM-DD`). The handler resolves it and dispatches to the per-height or
|
||||
per-day variant, choosing the matching cache strategy.
|
||||
|
||||
Attributes:
|
||||
point: Confirmed block height as decimal digits (`840000`) or calendar date in
|
||||
`YYYY-MM-DD` format.
|
||||
"""
|
||||
point: str
|
||||
|
||||
@@ -1265,7 +1272,7 @@ class MerkleProof(TypedDict):
|
||||
|
||||
class NextBlockHashParam(TypedDict):
|
||||
"""
|
||||
`since` hash for `/api/v1/mempool/block-template/diff/{hash}`.
|
||||
Prior-template hash for `GET /api/v1/mempool/block-template/diff/{hash}`.
|
||||
"""
|
||||
hash: NextBlockHash
|
||||
|
||||
@@ -1801,6 +1808,9 @@ class UrpdCohortParam(TypedDict):
|
||||
class UrpdParams(TypedDict):
|
||||
"""
|
||||
Path parameters for `/api/urpd/{cohort}/{date}`.
|
||||
|
||||
Attributes:
|
||||
date: Calendar date of the URPD snapshot in `YYYY-MM-DD` format.
|
||||
"""
|
||||
cohort: Cohort
|
||||
date: str
|
||||
@@ -8479,7 +8489,7 @@ class BrkClient(BrkClientBase):
|
||||
def get_health(self) -> Health:
|
||||
"""Health check.
|
||||
|
||||
Liveness probe. Returns server identity, uptime, and indexed/computed heights from local state only (no bitcoind round-trip). For real chain-tip catch-up, see `/api/server/sync`.
|
||||
Liveness probe. Returns server identity, uptime, and indexed/computed heights from local state only (no bitcoind round-trip). For real chain-tip catch-up, request `GET /api/server/sync`.
|
||||
|
||||
Endpoint: `GET /health`"""
|
||||
return self.get_json('/health')
|
||||
@@ -8586,7 +8596,7 @@ class BrkClient(BrkClientBase):
|
||||
def get_series_data(self, series: SeriesName, index: Index, start: Optional[RangeIndex] = None, end: Optional[RangeIndex] = None, limit: Optional[Limit] = None, format: Optional[Format] = None) -> Union[List[bool], str]:
|
||||
"""Get raw series data.
|
||||
|
||||
Returns just the data array without the SeriesData wrapper. Supports the same range and format parameters as the standard endpoint.
|
||||
Returns just the data array without the SeriesData wrapper. Supports the same range and format parameters as `GET /api/series/{series}/{index}`.
|
||||
|
||||
Endpoint: `GET /api/series/{series}/{index}/data`"""
|
||||
params = []
|
||||
@@ -8666,9 +8676,7 @@ class BrkClient(BrkClientBase):
|
||||
def get_urpd(self, cohort: Cohort, agg: Optional[UrpdAggregation] = None, weight: Optional[UrpdWeight] = None) -> Urpd:
|
||||
"""Latest URPD.
|
||||
|
||||
URPD for the most recent available date in the cohort. The response's `date` field echoes which date was served.
|
||||
|
||||
See the URPD tag description for the response shape, `agg`, and `weight` options.
|
||||
URPD for the most recent available date in the cohort. The response's `date` field echoes which date was served. Returns `{ cohort, date, weight, aggregation, close, total_supply, buckets }`. `close` and each bucket's `price_floor`, `realized_cap`, and `unrealized_pnl` are USD; `total_supply` and bucket `supply` are BTC. `unrealized_pnl` can be negative.
|
||||
|
||||
Endpoint: `GET /api/urpd/{cohort}`"""
|
||||
params = []
|
||||
@@ -8681,9 +8689,7 @@ class BrkClient(BrkClientBase):
|
||||
def get_urpd_at(self, cohort: Cohort, date: str, agg: Optional[UrpdAggregation] = None, weight: Optional[UrpdWeight] = None) -> Urpd:
|
||||
"""URPD at date.
|
||||
|
||||
URPD for a (cohort, date) pair. Returns `{ cohort, date, weight, aggregation, close, total_supply, buckets }` where each bucket is `{ price_floor, supply, realized_cap, unrealized_pnl }`.
|
||||
|
||||
See the URPD tag description for unit conventions, `agg`, and `weight` options.
|
||||
URPD for a (cohort, date) pair. Returns `{ cohort, date, weight, aggregation, close, total_supply, buckets }` where each bucket is `{ price_floor, supply, realized_cap, unrealized_pnl }`. `close`, `price_floor`, `realized_cap`, and `unrealized_pnl` are USD; `total_supply` and `supply` are BTC. `unrealized_pnl` can be negative.
|
||||
|
||||
Endpoint: `GET /api/urpd/{cohort}/{date}`"""
|
||||
params = []
|
||||
@@ -8730,7 +8736,7 @@ class BrkClient(BrkClientBase):
|
||||
def get_address_hash_prefix_matches(self, addr_type: OutputType, prefix: str) -> AddrHashPrefixMatches:
|
||||
"""Address hash-prefix matches.
|
||||
|
||||
Find addresses by address type and by the first 1-16 hex nibbles of RapidHash v3 over the raw address payload bytes. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata for the returned addresses through `/api/address/{address}`.
|
||||
Find addresses by address type and by the first 1-16 hex nibbles of RapidHash v3 over the raw address payload bytes. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata with `GET /api/address/{address}`.
|
||||
|
||||
Endpoint: `GET /api/address/hash-prefix/{addr_type}/{prefix}`"""
|
||||
return self.get_json(f'/api/address/hash-prefix/{addr_type}/{prefix}')
|
||||
@@ -8748,7 +8754,7 @@ class BrkClient(BrkClientBase):
|
||||
def get_address_txs(self, address: Addr) -> List[Transaction]:
|
||||
"""Address transactions.
|
||||
|
||||
Get transaction history for an address, newest first. Returns up to 50 mempool transactions plus a confirmed page sized to fill the response to 50 total (chain floor of 25, so 25-50 confirmed depending on mempool weight). To paginate further confirmed history, use `/address/{address}/txs/chain/{last_seen_txid}`.
|
||||
Get transaction history for an address, newest first. Returns up to 50 mempool transactions plus a confirmed page sized to fill the response to 50 total (chain floor of 25, so 25-50 confirmed depending on mempool weight). To paginate further confirmed history, request `GET /api/address/{address}/txs/chain/{after_txid}` with the last returned txid.
|
||||
|
||||
*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*
|
||||
|
||||
@@ -8758,7 +8764,7 @@ class BrkClient(BrkClientBase):
|
||||
def get_address_confirmed_txs(self, address: Addr) -> List[Transaction]:
|
||||
"""Address confirmed transactions.
|
||||
|
||||
Get the first 25 confirmed transactions for an address. For pagination, use the path-style form `/txs/chain/{last_seen_txid}`.
|
||||
Get the first 25 confirmed transactions for an address. For pagination, request `GET /api/address/{address}/txs/chain/{after_txid}` with the last returned txid.
|
||||
|
||||
*[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)*
|
||||
|
||||
@@ -9226,7 +9232,7 @@ class BrkClient(BrkClientBase):
|
||||
def get_fullrbf_replacements(self) -> List[ReplacementNode]:
|
||||
"""Recent full-RBF replacements.
|
||||
|
||||
Like `/api/v1/replacements`, but limited to trees where at least one predecessor was non-signaling (full-RBF).
|
||||
Same response shape as `GET /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)*
|
||||
|
||||
@@ -9236,7 +9242,7 @@ class BrkClient(BrkClientBase):
|
||||
def get_block_template(self) -> BlockTemplate:
|
||||
"""Projected next block template.
|
||||
|
||||
Bitcoin Core's `getblocktemplate` selection: full transaction bodies in GBT order with aggregate stats. The returned `hash` is an opaque content token; pass it as `<hash>` on `/api/v1/mempool/block-template/diff/{hash}` to fetch deltas instead of refetching the whole template.
|
||||
Bitcoin Core's `getblocktemplate` selection: full transaction bodies in GBT order with aggregate stats. The returned `hash` is an opaque content token; pass it to `GET /api/v1/mempool/block-template/diff/{hash}` to fetch deltas instead of refetching the whole template.
|
||||
|
||||
Endpoint: `GET /api/v1/mempool/block-template`"""
|
||||
return self.get_json('/api/v1/mempool/block-template')
|
||||
@@ -9244,7 +9250,7 @@ class BrkClient(BrkClientBase):
|
||||
def get_block_template_diff(self, hash: NextBlockHash) -> BlockTemplateDiff:
|
||||
"""Block template diff since hash.
|
||||
|
||||
Delta of the projected next block since `<hash>`. `order` is the full new template in order: each entry is either a number (index into the prior template the client cached at `<hash>`) or a transaction object (new body to insert at this position). Walk `order` once to rebuild; `removed` is a convenience list of txids that left so clients can evict cached bodies. After applying, use the response `hash` as `<hash>` on the next call to keep iterating. Returns `404` when `<hash>` has aged out of server history; clients should fall back to `/api/v1/mempool/block-template`.
|
||||
Delta of the projected next block since `<hash>`. `order` is the full new template in order: each entry is either a number (index into the prior template the client cached at `<hash>`) or a transaction object (new body to insert at this position). Walk `order` once to rebuild; `removed` is a convenience list of txids that left so clients can evict cached bodies. After applying, use the response `hash` as `<hash>` on the next call to keep iterating. Returns `404` when `<hash>` has aged out of server history; clients should fall back to `GET /api/v1/mempool/block-template`.
|
||||
|
||||
Endpoint: `GET /api/v1/mempool/block-template/diff/{hash}`"""
|
||||
return self.get_json(f'/api/v1/mempool/block-template/diff/{hash}')
|
||||
@@ -9260,7 +9266,7 @@ class BrkClient(BrkClientBase):
|
||||
def get_oracle_price(self) -> Dollars:
|
||||
"""Live BTC/USD price.
|
||||
|
||||
Current BTC/USD price in dollars. Same value as `/api/mempool/price`. Confirmed per-height history is available at `/api/vecs/height-to-price`.
|
||||
Current BTC/USD price in dollars. Same value as `GET /api/mempool/price`. Confirmed per-height history is available at `GET /api/series/price/height`.
|
||||
|
||||
Endpoint: `GET /api/oracle/price`"""
|
||||
return self.get_json('/api/oracle/price')
|
||||
@@ -9440,7 +9446,7 @@ class BrkClient(BrkClientBase):
|
||||
def get_api(self) -> Any:
|
||||
"""Compact OpenAPI specification.
|
||||
|
||||
Compact OpenAPI specification optimized for LLM consumption. Removes redundant fields while preserving essential API information. Full spec available at `/openapi.json`.
|
||||
Compact OpenAPI specification optimized for LLM consumption. Removes redundant fields while preserving essential API information. The full specification is available at `GET /openapi.json`.
|
||||
|
||||
Endpoint: `GET /api.json`"""
|
||||
return self.get_json('/api.json')
|
||||
|
||||
Generated
+27
-27
@@ -50,7 +50,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "brk-client"
|
||||
version = "0.3.0"
|
||||
version = "0.3.6"
|
||||
source = { editable = "." }
|
||||
|
||||
[package.dev-dependencies]
|
||||
@@ -196,7 +196,7 @@ resolution-markers = [
|
||||
"python_full_version < '3.10'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/2e/0090cbf739cee7d23781ad4b89a9894a41538e4fcf4c31dcdd705b78eb8b/click-8.1.8.tar.gz", hash = "sha256:ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a", size = 226593, upload-time = "2024-12-21T18:38:44.339Z" }
|
||||
wheels = [
|
||||
@@ -217,7 +217,7 @@ resolution-markers = [
|
||||
"python_full_version == '3.10.*'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
||||
wheels = [
|
||||
@@ -325,7 +325,7 @@ name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
@@ -744,11 +744,11 @@ resolution-markers = [
|
||||
"python_full_version < '3.10'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
{ name = "numpy", version = "2.0.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version != '3.10.*'" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.10.*'" },
|
||||
{ name = "python-dateutil", marker = "python_full_version < '3.11'" },
|
||||
{ name = "pytz", marker = "python_full_version < '3.11'" },
|
||||
{ name = "tzdata", marker = "python_full_version < '3.11'" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "pytz" },
|
||||
{ name = "tzdata" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" }
|
||||
wheels = [
|
||||
@@ -821,9 +821,9 @@ resolution-markers = [
|
||||
"python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "python-dateutil", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "tzdata", marker = "(python_full_version >= '3.11' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and sys_platform == 'win32')" },
|
||||
{ name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/da/b1dc0481ab8d55d0f46e343cfe67d4551a0e14fcee52bd38ca1bd73258d8/pandas-3.0.0.tar.gz", hash = "sha256:0facf7e87d38f721f0af46fe70d97373a37701b1c09f7ed7aeeb292ade5c050f", size = 4633005, upload-time = "2026-01-21T15:52:04.726Z" }
|
||||
wheels = [
|
||||
@@ -932,7 +932,7 @@ resolution-markers = [
|
||||
"python_full_version < '3.10'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "polars-runtime-32", version = "1.36.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
{ name = "polars-runtime-32", version = "1.36.1", source = { registry = "https://pypi.org/simple" } },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/dc/56f2a90c79a2cb13f9e956eab6385effe54216ae7a2068b3a6406bae4345/polars-1.36.1.tar.gz", hash = "sha256:12c7616a2305559144711ab73eaa18814f7aa898c522e7645014b68f1432d54c", size = 711993, upload-time = "2025-12-10T01:14:53.033Z" }
|
||||
wheels = [
|
||||
@@ -953,7 +953,7 @@ resolution-markers = [
|
||||
"python_full_version == '3.10.*'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "polars-runtime-32", version = "1.37.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
||||
{ name = "polars-runtime-32", version = "1.37.1", source = { registry = "https://pypi.org/simple" } },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/84/ae/dfebf31b9988c20998140b54d5b521f64ce08879f2c13d9b4d44d7c87e32/polars-1.37.1.tar.gz", hash = "sha256:0309e2a4633e712513401964b4d95452f124ceabf7aec6db50affb9ced4a274e", size = 715572, upload-time = "2026-01-12T23:27:03.267Z" }
|
||||
wheels = [
|
||||
@@ -1045,13 +1045,13 @@ resolution-markers = [
|
||||
"python_full_version < '3.10'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.10'" },
|
||||
{ name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
|
||||
{ name = "packaging", marker = "python_full_version < '3.10'" },
|
||||
{ name = "pluggy", marker = "python_full_version < '3.10'" },
|
||||
{ name = "pygments", marker = "python_full_version < '3.10'" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.10'" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup" },
|
||||
{ name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
|
||||
wheels = [
|
||||
@@ -1072,13 +1072,13 @@ resolution-markers = [
|
||||
"python_full_version == '3.10.*'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "python_full_version >= '3.10' and sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version == '3.10.*'" },
|
||||
{ name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
|
||||
{ name = "packaging", marker = "python_full_version >= '3.10'" },
|
||||
{ name = "pluggy", marker = "python_full_version >= '3.10'" },
|
||||
{ name = "pygments", marker = "python_full_version >= '3.10'" },
|
||||
{ name = "tomli", marker = "python_full_version == '3.10.*'" },
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" } },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
|
||||
wheels = [
|
||||
|
||||
+17
-17
@@ -17,11 +17,11 @@ For machine-readable tool construction, use [https://bitview.space/openapi.json]
|
||||
|
||||
Address hash-prefix matches
|
||||
|
||||
Find addresses by address type and by the first 1-16 hex nibbles of RapidHash v3 over the raw address payload bytes. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata for the returned addresses through `/api/address/{address}`.
|
||||
Find addresses by address type and by the first 1-16 hex nibbles of RapidHash v3 over the raw address payload bytes. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata with `GET /api/address/{address}`.
|
||||
|
||||
Parameters:
|
||||
- `addr_type` (path, OutputType, required)
|
||||
- `prefix` (path, string, required)
|
||||
- `prefix` (path, string, required): First 1–16 hexadecimal nibbles of the RapidHash v3 hash over the raw address payload bytes.
|
||||
|
||||
Returns: JSON `AddrHashPrefixMatches`
|
||||
|
||||
@@ -48,7 +48,7 @@ curl -s "https://bitview.space/api/address/<address>"
|
||||
|
||||
Address transactions
|
||||
|
||||
Get transaction history for an address, newest first. Returns up to 50 mempool transactions plus a confirmed page sized to fill the response to 50 total (chain floor of 25, so 25-50 confirmed depending on mempool weight). To paginate further confirmed history, use `/address/{address}/txs/chain/{last_seen_txid}`. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*
|
||||
Get transaction history for an address, newest first. Returns up to 50 mempool transactions plus a confirmed page sized to fill the response to 50 total (chain floor of 25, so 25-50 confirmed depending on mempool weight). To paginate further confirmed history, request `GET /api/address/{address}/txs/chain/{after_txid}` with the last returned txid. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*
|
||||
|
||||
Parameters:
|
||||
- `address` (path, Addr, required)
|
||||
@@ -63,7 +63,7 @@ curl -s "https://bitview.space/api/address/<address>/txs"
|
||||
|
||||
Address confirmed transactions
|
||||
|
||||
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)*
|
||||
Get the first 25 confirmed transactions for an address. For pagination, request `GET /api/address/{address}/txs/chain/{after_txid}` with the last returned txid. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)*
|
||||
|
||||
Parameters:
|
||||
- `address` (path, Addr, required)
|
||||
@@ -126,7 +126,7 @@ curl -s "https://bitview.space/api/address/<address>/utxo"
|
||||
|
||||
Compact OpenAPI specification
|
||||
|
||||
Compact OpenAPI specification optimized for LLM consumption. Removes redundant fields while preserving essential API information. Full spec available at `/openapi.json`.
|
||||
Compact OpenAPI specification optimized for LLM consumption. Removes redundant fields while preserving essential API information. The full specification is available at `GET /openapi.json`.
|
||||
|
||||
Returns: JSON `*`
|
||||
|
||||
@@ -445,7 +445,7 @@ curl -s "https://bitview.space/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)*
|
||||
Same response shape as `GET /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)*
|
||||
|
||||
Returns: JSON `ReplacementNode[]`
|
||||
|
||||
@@ -459,7 +459,7 @@ curl -s "https://bitview.space/api/v1/fullrbf/replacements"
|
||||
|
||||
Health check
|
||||
|
||||
Liveness probe. Returns server identity, uptime, and indexed/computed heights from local state only (no bitcoind round-trip). For real chain-tip catch-up, see `/api/server/sync`.
|
||||
Liveness probe. Returns server identity, uptime, and indexed/computed heights from local state only (no bitcoind round-trip). For real chain-tip catch-up, request `GET /api/server/sync`.
|
||||
|
||||
Returns: JSON `Health`
|
||||
|
||||
@@ -550,7 +550,7 @@ curl -s "https://bitview.space/api/mempool/txids"
|
||||
|
||||
Projected next block template
|
||||
|
||||
Bitcoin Core's `getblocktemplate` selection: full transaction bodies in GBT order with aggregate stats. The returned `hash` is an opaque content token; pass it as `<hash>` on `/api/v1/mempool/block-template/diff/{hash}` to fetch deltas instead of refetching the whole template.
|
||||
Bitcoin Core's `getblocktemplate` selection: full transaction bodies in GBT order with aggregate stats. The returned `hash` is an opaque content token; pass it to `GET /api/v1/mempool/block-template/diff/{hash}` to fetch deltas instead of refetching the whole template.
|
||||
|
||||
Returns: JSON `BlockTemplate`
|
||||
|
||||
@@ -562,7 +562,7 @@ curl -s "https://bitview.space/api/v1/mempool/block-template"
|
||||
|
||||
Block template diff since hash
|
||||
|
||||
Delta of the projected next block since `<hash>`. `order` is the full new template in order: each entry is either a number (index into the prior template the client cached at `<hash>`) or a transaction object (new body to insert at this position). Walk `order` once to rebuild; `removed` is a convenience list of txids that left so clients can evict cached bodies. After applying, use the response `hash` as `<hash>` on the next call to keep iterating. Returns `404` when `<hash>` has aged out of server history; clients should fall back to `/api/v1/mempool/block-template`.
|
||||
Delta of the projected next block since `<hash>`. `order` is the full new template in order: each entry is either a number (index into the prior template the client cached at `<hash>`) or a transaction object (new body to insert at this position). Walk `order` once to rebuild; `removed` is a convenience list of txids that left so clients can evict cached bodies. After applying, use the response `hash` as `<hash>` on the next call to keep iterating. Returns `404` when `<hash>` has aged out of server history; clients should fall back to `GET /api/v1/mempool/block-template`.
|
||||
|
||||
Parameters:
|
||||
- `hash` (path, NextBlockHash, required)
|
||||
@@ -869,7 +869,7 @@ Output value histogram at height or day
|
||||
Unfiltered output value histogram for a confirmed point. A block height (`840000`) gives every output in that block, coinbase included, binned by value on the oracle log scale; a calendar date (`YYYY-MM-DD`) sums every block that day. A flat array of log-scale bins.
|
||||
|
||||
Parameters:
|
||||
- `point` (path, string, required)
|
||||
- `point` (path, string, required): Confirmed block height as decimal digits (`840000`) or calendar date in `YYYY-MM-DD` format.
|
||||
|
||||
Returns: JSON `integer[]`
|
||||
|
||||
@@ -896,7 +896,7 @@ Payment output histogram at height or day
|
||||
Smoothed histogram of oracle-eligible payment outputs for a confirmed point. A block height (`840000`) gives that block's oracle payment histogram; a calendar date (`YYYY-MM-DD`) gives the average of that day's per-block payment histograms. A flat array of log-scale bins.
|
||||
|
||||
Parameters:
|
||||
- `point` (path, string, required)
|
||||
- `point` (path, string, required): Confirmed block height as decimal digits (`840000`) or calendar date in `YYYY-MM-DD` format.
|
||||
|
||||
Returns: JSON `integer[]`
|
||||
|
||||
@@ -908,7 +908,7 @@ curl -s "https://bitview.space/api/oracle/histogram/payments/<point>"
|
||||
|
||||
Live BTC/USD price
|
||||
|
||||
Current BTC/USD price in dollars. Same value as `/api/mempool/price`. Confirmed per-height history is available at `/api/vecs/height-to-price`.
|
||||
Current BTC/USD price in dollars. Same value as `GET /api/mempool/price`. Confirmed per-height history is available at `GET /api/series/price/height`.
|
||||
|
||||
Returns: JSON `Dollars`
|
||||
|
||||
@@ -1073,7 +1073,7 @@ curl -s "https://bitview.space/api/series/<series>/<index>?start=<start>&end=<en
|
||||
|
||||
Get raw series data
|
||||
|
||||
Returns just the data array without the SeriesData wrapper. Supports the same range and format parameters as the standard endpoint.
|
||||
Returns just the data array without the SeriesData wrapper. Supports the same range and format parameters as `GET /api/series/{series}/{index}`.
|
||||
|
||||
Parameters:
|
||||
- `series` (path, SeriesName, required): Series name
|
||||
@@ -1367,7 +1367,7 @@ curl -s "https://bitview.space/api/urpd"
|
||||
|
||||
Latest URPD
|
||||
|
||||
URPD for the most recent available date in the cohort. The response's `date` field echoes which date was served. See the URPD tag description for the response shape, `agg`, and `weight` options.
|
||||
URPD for the most recent available date in the cohort. The response's `date` field echoes which date was served. Returns `{ cohort, date, weight, aggregation, close, total_supply, buckets }`. `close` and each bucket's `price_floor`, `realized_cap`, and `unrealized_pnl` are USD; `total_supply` and bucket `supply` are BTC. `unrealized_pnl` can be negative.
|
||||
|
||||
Parameters:
|
||||
- `cohort` (path, Cohort, required)
|
||||
@@ -1400,11 +1400,11 @@ curl -s "https://bitview.space/api/urpd/<cohort>/dates?weight=<weight>"
|
||||
|
||||
URPD at date
|
||||
|
||||
URPD for a (cohort, date) pair. Returns `{ cohort, date, weight, aggregation, close, total_supply, buckets }` where each bucket is `{ price_floor, supply, realized_cap, unrealized_pnl }`. See the URPD tag description for unit conventions, `agg`, and `weight` options.
|
||||
URPD for a (cohort, date) pair. Returns `{ cohort, date, weight, aggregation, close, total_supply, buckets }` where each bucket is `{ price_floor, supply, realized_cap, unrealized_pnl }`. `close`, `price_floor`, `realized_cap`, and `unrealized_pnl` are USD; `total_supply` and `supply` are BTC. `unrealized_pnl` can be negative.
|
||||
|
||||
Parameters:
|
||||
- `cohort` (path, Cohort, required)
|
||||
- `date` (path, string, required)
|
||||
- `date` (path, string, required): Calendar date of the URPD snapshot in `YYYY-MM-DD` format.
|
||||
- `agg` (query, UrpdAggregation, optional): Aggregation strategy. Default: raw (no aggregation). Accepts `bucket` as alias.
|
||||
- `weight` (query, UrpdWeight, optional): Supply weighting. Default: raw (unweighted).
|
||||
|
||||
@@ -1599,7 +1599,7 @@ curl -s "https://bitview.space/version"
|
||||
|
||||
### `BlockTemplate`
|
||||
|
||||
- `hash`: `NextBlockHash` (required) — Pass back as `<hash>` on `/api/v1/mempool/block-template/diff/{hash}` to fetch deltas.
|
||||
- `hash`: `NextBlockHash` (required) — Pass to `GET /api/v1/mempool/block-template/diff/{hash}` to fetch deltas.
|
||||
- `stats`: `MempoolBlock` (required) — Aggregate stats for this block (size, vsize, fee range, ...).
|
||||
- `transactions`: `Transaction[]` (required) — Full transaction bodies in `getblocktemplate` order.
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { colors } from "../../utils/colors.js";
|
||||
import { Unit } from "../../utils/units.js";
|
||||
import { ageRanges } from "../age-ranges.js";
|
||||
import { line, price } from "../series.js";
|
||||
import { satsBtcUsd } from "../shared.js";
|
||||
import { satsBtcUsd, simplePriceRatioTree } from "../shared.js";
|
||||
|
||||
/**
|
||||
* @typedef {Object} CoinflowAgeRange
|
||||
@@ -76,6 +76,10 @@ export function createCoinflowSection() {
|
||||
{ name: "All", color: colors.loss, tree: coinflow },
|
||||
...terms,
|
||||
];
|
||||
const frameworkCohorts = [
|
||||
{ name: "All", color: colors.coinflow, tree: coinflow },
|
||||
...terms,
|
||||
];
|
||||
|
||||
const horizons = /** @type {const} */ ([
|
||||
{ key: "_8y", name: "8Y" },
|
||||
@@ -93,11 +97,75 @@ export function createCoinflowSection() {
|
||||
return {
|
||||
name: "Coinflow",
|
||||
tree: [
|
||||
{
|
||||
name: "Price",
|
||||
tree: [
|
||||
{
|
||||
name: "Compare",
|
||||
title: "Coinflow Price by Holder Term",
|
||||
top: frameworkCohorts.map(({ name, color, tree }) =>
|
||||
price({
|
||||
series: tree.price,
|
||||
name,
|
||||
color,
|
||||
}),
|
||||
),
|
||||
bottom: frameworkCohorts.map(({ name, color, tree }) =>
|
||||
line({
|
||||
series: tree.price.ratio,
|
||||
name: `Spot / ${name}`,
|
||||
color,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
),
|
||||
},
|
||||
...frameworkCohorts.map(({ name, color, tree }) => {
|
||||
const title =
|
||||
name === "All" ? "Coinflow Price" : `${name} Coinflow Price`;
|
||||
const [chart] = simplePriceRatioTree({
|
||||
pattern: tree.price,
|
||||
title,
|
||||
legend: name,
|
||||
color,
|
||||
});
|
||||
return { ...chart, name };
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Capitalization",
|
||||
tree: [
|
||||
{
|
||||
name: "Compare",
|
||||
title: "Coinflow Cap by Holder Term",
|
||||
bottom: frameworkCohorts.map(({ name, color, tree }) =>
|
||||
line({
|
||||
series: tree.cap.usd,
|
||||
name,
|
||||
color,
|
||||
unit: Unit.usd,
|
||||
}),
|
||||
),
|
||||
},
|
||||
...frameworkCohorts.map(({ name, color, tree }) => ({
|
||||
name,
|
||||
title: name === "All" ? "Coinflow Cap" : `${name} Coinflow Cap`,
|
||||
bottom: [
|
||||
line({
|
||||
series: tree.cap.usd,
|
||||
name,
|
||||
color,
|
||||
unit: Unit.usd,
|
||||
}),
|
||||
],
|
||||
})),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Supply",
|
||||
tree: [
|
||||
{
|
||||
name: "Breakdown",
|
||||
name: "Overview",
|
||||
title: "Mobile vs Immobile Supply",
|
||||
bottom: [
|
||||
...satsBtcUsd({
|
||||
@@ -113,130 +181,96 @@ export function createCoinflowSection() {
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Mobile by Term",
|
||||
title: "Mobile Supply by Holder Term",
|
||||
bottom: terms.flatMap(({ name, color, tree }) =>
|
||||
satsBtcUsd({
|
||||
pattern: tree.supply.mobile,
|
||||
name,
|
||||
color,
|
||||
}),
|
||||
),
|
||||
name: "By Holder Term",
|
||||
tree: [
|
||||
{
|
||||
name: "Mobile",
|
||||
title: "Mobile Supply by Holder Term",
|
||||
bottom: terms.flatMap(({ name, color, tree }) =>
|
||||
satsBtcUsd({
|
||||
pattern: tree.supply.mobile,
|
||||
name,
|
||||
color,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Immobile",
|
||||
title: "Immobile Supply by Holder Term",
|
||||
bottom: terms.flatMap(({ name, color, tree }) =>
|
||||
satsBtcUsd({
|
||||
pattern: tree.supply.immobile,
|
||||
name,
|
||||
color,
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Immobile by Term",
|
||||
title: "Immobile Supply by Holder Term",
|
||||
bottom: terms.flatMap(({ name, color, tree }) =>
|
||||
satsBtcUsd({
|
||||
pattern: tree.supply.immobile,
|
||||
name,
|
||||
color,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Mobile in Loss",
|
||||
title: "Mobile Supply in Loss by Holder Term",
|
||||
bottom: cohorts.map(({ name, color, tree }) =>
|
||||
line({
|
||||
series: tree.supply.mobile.inLoss.share,
|
||||
name,
|
||||
color,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "In Loss by Horizon",
|
||||
tree: cohorts.map(({ name, tree }) => ({
|
||||
name,
|
||||
title:
|
||||
name === "All"
|
||||
? "Mobile Supply in Loss by Horizon"
|
||||
: `${name} Mobile Supply in Loss by Horizon`,
|
||||
bottom: horizons.map((horizon) =>
|
||||
line({
|
||||
series: tree.horizon[horizon.key].supply.inLoss.share,
|
||||
name: horizon.name,
|
||||
color: horizon.color,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
),
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Cap",
|
||||
title: "Coinflow Cap",
|
||||
bottom: [
|
||||
line({
|
||||
series: coinflow.cap.usd,
|
||||
name: "All",
|
||||
color: colors.coinflow,
|
||||
unit: Unit.usd,
|
||||
}),
|
||||
...terms.map(({ name, color, tree }) =>
|
||||
line({
|
||||
series: tree.cap.usd,
|
||||
name,
|
||||
color,
|
||||
unit: Unit.usd,
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Price",
|
||||
title: "Coinflow Price",
|
||||
top: [
|
||||
price({
|
||||
series: coinflow.price,
|
||||
name: "All",
|
||||
color: colors.coinflow,
|
||||
}),
|
||||
...terms.map(({ name, color, tree }) =>
|
||||
price({
|
||||
series: tree.price,
|
||||
name,
|
||||
color,
|
||||
}),
|
||||
),
|
||||
],
|
||||
bottom: [
|
||||
line({
|
||||
series: coinflow.price.ratio,
|
||||
name: "Spot / All",
|
||||
color: colors.coinflow,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
...terms.map(({ name, color, tree }) =>
|
||||
line({
|
||||
series: tree.price.ratio,
|
||||
name: `Spot / ${name}`,
|
||||
color,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Age Range",
|
||||
tree: [
|
||||
ageRangeRatioChart(ranges, "mobility", "Mobility"),
|
||||
ageRangeRatioChart(ranges, "spendingRate", "Spending Rate"),
|
||||
ageRangeRatioChart(
|
||||
ranges,
|
||||
"spendingExposure",
|
||||
"Spending Exposure",
|
||||
),
|
||||
{
|
||||
name: "Supply",
|
||||
name: "By UTXO Age",
|
||||
tree: [
|
||||
ageRangeSupplyChart(ranges, "mobile", "Mobile"),
|
||||
ageRangeSupplyChart(ranges, "immobile", "Immobile"),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "In Loss",
|
||||
tree: [
|
||||
{
|
||||
name: "By Holder Term",
|
||||
title: "Mobile Supply in Loss by Holder Term",
|
||||
bottom: cohorts.map(({ name, color, tree }) =>
|
||||
line({
|
||||
series: tree.supply.mobile.inLoss.share,
|
||||
name,
|
||||
color,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "By Horizon",
|
||||
tree: cohorts.map(({ name, tree }) => ({
|
||||
name,
|
||||
title:
|
||||
name === "All"
|
||||
? "Mobile Supply in Loss by Horizon"
|
||||
: `${name} Mobile Supply in Loss by Horizon`,
|
||||
bottom: horizons.map((horizon) =>
|
||||
line({
|
||||
series:
|
||||
tree.horizon[horizon.key].supply.inLoss.share,
|
||||
name: horizon.name,
|
||||
color: horizon.color,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
),
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Activity",
|
||||
tree: [
|
||||
{
|
||||
name: "By UTXO Age",
|
||||
tree: [
|
||||
ageRangeRatioChart(ranges, "mobility", "Mobility"),
|
||||
ageRangeRatioChart(
|
||||
ranges,
|
||||
"spendingRate",
|
||||
"Spending Rate",
|
||||
),
|
||||
ageRangeRatioChart(
|
||||
ranges,
|
||||
"spendingExposure",
|
||||
"Spending Exposure",
|
||||
),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -75,36 +75,26 @@ function coindaysTree(ranges, key, name) {
|
||||
return {
|
||||
name,
|
||||
tree: [
|
||||
{
|
||||
name: "Average",
|
||||
tree: ROLLING_WINDOWS.map((window) => ({
|
||||
name: window.name,
|
||||
title: `${window.title} Average ${name} by UTXO Age`,
|
||||
bottom: ranges.map((range) =>
|
||||
line({
|
||||
series: range.tree[key].average[window.key],
|
||||
name: range.name,
|
||||
color: range.color,
|
||||
unit: Unit.coindays,
|
||||
}),
|
||||
),
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "Sum",
|
||||
tree: ROLLING_WINDOWS.map((window) => ({
|
||||
name: window.name,
|
||||
title: `${window.title} ${name} by UTXO Age`,
|
||||
bottom: ranges.map((range) =>
|
||||
line({
|
||||
series: range.tree[key].sum[window.key],
|
||||
name: range.name,
|
||||
color: range.color,
|
||||
unit: Unit.coindays,
|
||||
}),
|
||||
),
|
||||
})),
|
||||
},
|
||||
...ROLLING_WINDOWS.map((window) => ({
|
||||
name: window.name,
|
||||
title: `${window.title} ${name} by UTXO Age`,
|
||||
bottom: ranges.flatMap((range) => [
|
||||
line({
|
||||
series: range.tree[key].sum[window.key],
|
||||
name: range.name,
|
||||
color: range.color,
|
||||
unit: Unit.coindays,
|
||||
}),
|
||||
line({
|
||||
series: range.tree[key].average[window.key],
|
||||
name: `${range.name} Avg`,
|
||||
color: range.color,
|
||||
unit: Unit.coindays,
|
||||
defaultActive: false,
|
||||
style: 1,
|
||||
}),
|
||||
]),
|
||||
})),
|
||||
{
|
||||
name: "Cumulative",
|
||||
title: `Cumulative ${name} by UTXO Age`,
|
||||
@@ -125,35 +115,37 @@ function coindaysTree(ranges, key, name) {
|
||||
* @param {readonly CointimeAgeRange[]} ranges
|
||||
* @returns {PartialOptionsGroup}
|
||||
*/
|
||||
export function createCointimeAgeRangeSection(ranges) {
|
||||
export function createCointimeAgeRangeSupplySection(ranges) {
|
||||
return {
|
||||
name: "Age Range",
|
||||
name: "By UTXO Age",
|
||||
tree: [
|
||||
{
|
||||
name: "Supply",
|
||||
tree: [
|
||||
supplyChart(ranges, "awake", "Awake"),
|
||||
supplyChart(ranges, "dormant", "Dormant"),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Activity",
|
||||
tree: [
|
||||
activityChart(
|
||||
ranges,
|
||||
"wakefulness",
|
||||
"Wakefulness",
|
||||
"Wakefulness",
|
||||
),
|
||||
activityChart(ranges, "dormancy", "Dormancy", "Dormancy"),
|
||||
activityChart(
|
||||
ranges,
|
||||
"wakefulnessToDormancy",
|
||||
"Activity Ratio",
|
||||
"Wakefulness / Dormancy",
|
||||
),
|
||||
],
|
||||
},
|
||||
supplyChart(ranges, "awake", "Awake"),
|
||||
supplyChart(ranges, "dormant", "Dormant"),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {readonly CointimeAgeRange[]} ranges
|
||||
* @returns {PartialOptionsGroup}
|
||||
*/
|
||||
export function createCointimeAgeRangeActivitySection(ranges) {
|
||||
return {
|
||||
name: "By UTXO Age",
|
||||
tree: [
|
||||
activityChart(
|
||||
ranges,
|
||||
"wakefulness",
|
||||
"Wakefulness",
|
||||
"Wakefulness",
|
||||
),
|
||||
activityChart(ranges, "dormancy", "Dormancy", "Dormancy"),
|
||||
activityChart(
|
||||
ranges,
|
||||
"wakefulnessToDormancy",
|
||||
"Activity Ratio",
|
||||
"Wakefulness / Dormancy",
|
||||
),
|
||||
{
|
||||
name: "Coindays",
|
||||
tree: [
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
} from "../../series.js";
|
||||
import { ageRanges } from "../../age-ranges.js";
|
||||
import { satsBtcUsd, simplePriceRatioTree } from "../../shared.js";
|
||||
import { createCointimeAgeRangeSection } from "./age-range.js";
|
||||
import {
|
||||
createCointimeAgeRangeActivitySection,
|
||||
createCointimeAgeRangeSupplySection,
|
||||
} from "./age-range.js";
|
||||
|
||||
/**
|
||||
* Create Cointime section
|
||||
@@ -42,7 +45,6 @@ export function createCointimeSection() {
|
||||
|
||||
// Reference lines for cap comparisons
|
||||
const capReferenceLines = /** @type {const} */ ([
|
||||
{ series: supply.marketCap.usd, name: "Market", color: colors.default },
|
||||
{
|
||||
series: all.realized.cap.usd,
|
||||
name: "Realized",
|
||||
@@ -183,7 +185,7 @@ export function createCointimeSection() {
|
||||
name: "Cointime",
|
||||
tree: [
|
||||
{
|
||||
name: "Prices",
|
||||
name: "Price",
|
||||
tree: [
|
||||
{
|
||||
name: "Compare",
|
||||
@@ -202,27 +204,15 @@ export function createCointimeSection() {
|
||||
...prices.map(({ pattern, name, color, defaultActive }) =>
|
||||
price({ series: pattern, name, color, defaultActive }),
|
||||
),
|
||||
...awakeCohorts.map(({ name, color, tree }) =>
|
||||
price({
|
||||
series: tree.awake.price,
|
||||
name: name === "All" ? "Awake" : `${name} Awake`,
|
||||
color,
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Awake",
|
||||
title: "Awake Price by Holder Term",
|
||||
top: awakeCohorts.map(({ name, color, tree }) =>
|
||||
price({
|
||||
series: tree.awake.price,
|
||||
name,
|
||||
color,
|
||||
}),
|
||||
),
|
||||
bottom: awakeCohorts.map(({ name, color, tree }) =>
|
||||
line({
|
||||
series: tree.awake.price.ratio,
|
||||
name: `Spot / ${name}`,
|
||||
color,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
),
|
||||
},
|
||||
...prices.map(({ pattern, name, title, color }) => {
|
||||
const [chart] = simplePriceRatioTree({
|
||||
pattern,
|
||||
@@ -232,11 +222,21 @@ export function createCointimeSection() {
|
||||
});
|
||||
return { ...chart, name };
|
||||
}),
|
||||
...awakeCohorts.map(({ name, color, tree }) => {
|
||||
const awakeName = name === "All" ? "Awake" : `${name} Awake`;
|
||||
const [chart] = simplePriceRatioTree({
|
||||
pattern: tree.awake.price,
|
||||
title: `${awakeName} Price`,
|
||||
legend: awakeName,
|
||||
color,
|
||||
});
|
||||
return { ...chart, name: awakeName };
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
name: "Caps",
|
||||
name: "Capitalization",
|
||||
tree: [
|
||||
{
|
||||
name: "Compare",
|
||||
@@ -248,20 +248,16 @@ export function createCointimeSection() {
|
||||
...caps.map(({ series, name, color, defaultActive }) =>
|
||||
line({ series, name, color, defaultActive, unit: Unit.usd }),
|
||||
),
|
||||
...awakeCohorts.map(({ name, color, tree }) =>
|
||||
line({
|
||||
series: tree.awake.cap.usd,
|
||||
name: name === "All" ? "Awake" : `${name} Awake`,
|
||||
color,
|
||||
unit: Unit.usd,
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Awake",
|
||||
title: "Awake Cap by Holder Term",
|
||||
bottom: awakeCohorts.map(({ name, color, tree }) =>
|
||||
line({
|
||||
series: tree.awake.cap.usd,
|
||||
name,
|
||||
color,
|
||||
unit: Unit.usd,
|
||||
}),
|
||||
),
|
||||
},
|
||||
...caps.map(({ series, name, color }) => ({
|
||||
name,
|
||||
title: `${name} Cap`,
|
||||
@@ -277,6 +273,29 @@ export function createCointimeSection() {
|
||||
),
|
||||
],
|
||||
})),
|
||||
...awakeCohorts.map(({ name, color, tree }) => {
|
||||
const awakeName = name === "All" ? "Awake" : `${name} Awake`;
|
||||
return {
|
||||
name: awakeName,
|
||||
title: `${awakeName} Cap`,
|
||||
bottom: [
|
||||
line({
|
||||
series: tree.awake.cap.usd,
|
||||
name: awakeName,
|
||||
color,
|
||||
unit: Unit.usd,
|
||||
}),
|
||||
...capReferenceLines.map((ref) =>
|
||||
line({
|
||||
series: ref.series,
|
||||
name: ref.name,
|
||||
color: ref.color,
|
||||
unit: Unit.usd,
|
||||
}),
|
||||
),
|
||||
],
|
||||
};
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -284,264 +303,296 @@ export function createCointimeSection() {
|
||||
name: "Supply",
|
||||
tree: [
|
||||
{
|
||||
name: "Breakdown",
|
||||
title: "Active vs Vaulted Supply",
|
||||
bottom: supplyBreakdown.flatMap(({ pattern, name, color }) =>
|
||||
satsBtcUsd({ pattern, name, color }),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Active in Loss",
|
||||
title: "Active Supply in Loss",
|
||||
bottom: [
|
||||
line({
|
||||
series: cointimeSupply.active.inLoss.share,
|
||||
name: "Share",
|
||||
color: colors.loss,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
name: "Overview",
|
||||
tree: [
|
||||
{
|
||||
name: "Active vs Vaulted",
|
||||
title: "Active vs Vaulted Supply",
|
||||
bottom: supplyBreakdown.flatMap(({ pattern, name, color }) =>
|
||||
satsBtcUsd({ pattern, name, color }),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Awake vs Dormant",
|
||||
title: "Awake vs Dormant Supply",
|
||||
bottom: [
|
||||
...satsBtcUsd({
|
||||
pattern: cointime.awake.supply,
|
||||
name: "Awake",
|
||||
color: colors.awake,
|
||||
}),
|
||||
...satsBtcUsd({
|
||||
pattern: cointime.dormant.supply,
|
||||
name: "Dormant",
|
||||
color: colors.dormant,
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Awake vs Dormant",
|
||||
title: "Awake vs Dormant Supply",
|
||||
bottom: [
|
||||
...satsBtcUsd({
|
||||
pattern: cointime.awake.supply,
|
||||
name: "By Holder Term",
|
||||
tree: [
|
||||
{
|
||||
name: "Awake",
|
||||
color: colors.awake,
|
||||
}),
|
||||
...satsBtcUsd({
|
||||
pattern: cointime.dormant.supply,
|
||||
title: "Awake Supply by Holder Term",
|
||||
bottom: awakeCohorts.flatMap(({ name, color, tree }) =>
|
||||
satsBtcUsd({
|
||||
pattern: tree.awake.supply,
|
||||
name,
|
||||
color,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Dormant",
|
||||
color: colors.dormant,
|
||||
}),
|
||||
title: "Dormant Supply by Holder Term",
|
||||
bottom: awakeCohorts.flatMap(({ name, color, tree }) =>
|
||||
satsBtcUsd({
|
||||
pattern: tree.dormant.supply,
|
||||
name,
|
||||
color,
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
createCointimeAgeRangeSupplySection(cointimeAgeRanges),
|
||||
{
|
||||
name: "Awake by Term",
|
||||
title: "Awake Supply by Holder Term",
|
||||
bottom: awakeCohorts.flatMap(({ name, color, tree }) =>
|
||||
satsBtcUsd({
|
||||
pattern: tree.awake.supply,
|
||||
name,
|
||||
color,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Dormant by Term",
|
||||
title: "Dormant Supply by Holder Term",
|
||||
bottom: awakeCohorts.flatMap(({ name, color, tree }) =>
|
||||
satsBtcUsd({
|
||||
pattern: tree.dormant.supply,
|
||||
name,
|
||||
color,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Awake in Loss",
|
||||
title: "Awake Supply in Loss by Holder Term",
|
||||
bottom: awakeCohorts.map(({ name, color, tree }) =>
|
||||
line({
|
||||
series: tree.awake.supply.inLoss.share,
|
||||
name,
|
||||
color,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
),
|
||||
name: "In Loss",
|
||||
tree: [
|
||||
{
|
||||
name: "Active",
|
||||
title: "Active Supply in Loss",
|
||||
bottom: [
|
||||
line({
|
||||
series: cointimeSupply.active.inLoss.share,
|
||||
name: "Share",
|
||||
color: colors.loss,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Awake by Holder Term",
|
||||
title: "Awake Supply in Loss by Holder Term",
|
||||
bottom: awakeCohorts.map(({ name, color, tree }) =>
|
||||
line({
|
||||
series: tree.awake.supply.inLoss.share,
|
||||
name,
|
||||
color,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
createCointimeAgeRangeSection(cointimeAgeRanges),
|
||||
|
||||
{
|
||||
name: "Activity",
|
||||
title: "Liveliness & Vaultedness",
|
||||
bottom: [
|
||||
line({
|
||||
series: activity.liveliness,
|
||||
name: "Liveliness",
|
||||
color: colors.liveliness,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
line({
|
||||
series: activity.vaultedness,
|
||||
name: "Vaultedness",
|
||||
color: colors.vaulted,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
line({
|
||||
series: activity.ratio,
|
||||
name: "Liveliness / Vaultedness",
|
||||
color: colors.activity,
|
||||
unit: Unit.ratio,
|
||||
defaultActive: false,
|
||||
}),
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
name: "Coinblocks",
|
||||
tree: [
|
||||
...multiSeriesTree({
|
||||
entries: coinblocks.map(({ pattern, name, color }) => ({
|
||||
name,
|
||||
color,
|
||||
average: pattern.average,
|
||||
sum: pattern.sum,
|
||||
cumulative: pattern.cumulative,
|
||||
})),
|
||||
metric: "Coinblocks",
|
||||
unit: Unit.coinblocks,
|
||||
}),
|
||||
...coinblocks.map(({ pattern, name, title: metric, color }) => ({
|
||||
name,
|
||||
tree: sumsAndAveragesCumulative({
|
||||
sum: pattern.sum,
|
||||
average: pattern.average,
|
||||
cumulative: pattern.cumulative,
|
||||
metric,
|
||||
unit: Unit.coinblocks,
|
||||
color,
|
||||
}),
|
||||
})),
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
name: "Value",
|
||||
tree: [
|
||||
...multiSeriesTree({
|
||||
entries: [
|
||||
...cointimeValues.map(({ pattern, name, color }) => ({
|
||||
name,
|
||||
color,
|
||||
average: pattern.average,
|
||||
sum: pattern.sum,
|
||||
cumulative: pattern.cumulative,
|
||||
})),
|
||||
{
|
||||
name: vocdd.name,
|
||||
color: vocdd.color,
|
||||
average: vocdd.pattern.average,
|
||||
sum: vocdd.pattern.sum,
|
||||
cumulative: vocdd.pattern.cumulative,
|
||||
},
|
||||
],
|
||||
metric: "Cointime Value",
|
||||
unit: Unit.usd,
|
||||
}),
|
||||
...cointimeValues.map(({ pattern, name, title: metric, color }) => ({
|
||||
name,
|
||||
tree: sumsAndAveragesCumulative({
|
||||
sum: pattern.sum,
|
||||
average: pattern.average,
|
||||
cumulative: pattern.cumulative,
|
||||
metric,
|
||||
unit: Unit.usd,
|
||||
color,
|
||||
}),
|
||||
})),
|
||||
{
|
||||
name: vocdd.name,
|
||||
tree: sumsAndAveragesCumulative({
|
||||
sum: vocdd.pattern.sum,
|
||||
average: vocdd.pattern.average,
|
||||
cumulative: vocdd.pattern.cumulative,
|
||||
metric: vocdd.title,
|
||||
unit: Unit.usd,
|
||||
color: vocdd.color,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
name: "Indicators",
|
||||
tree: [
|
||||
{
|
||||
name: "AVIV",
|
||||
title: "AVIV Ratio",
|
||||
name: "Overview",
|
||||
title: "Liveliness & Vaultedness",
|
||||
bottom: [
|
||||
line({
|
||||
series: cap.aviv.ratio,
|
||||
name: "AVIV",
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Reserve Risk",
|
||||
title: "Reserve Risk",
|
||||
bottom: [
|
||||
line({
|
||||
series: reserveRisk.value,
|
||||
name: "Ratio",
|
||||
color: colors.reserveRisk,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
name: "Adjusted",
|
||||
tree: [
|
||||
{
|
||||
name: "Inflation",
|
||||
title: "Cointime-Adjusted Inflation",
|
||||
bottom: [
|
||||
dots({
|
||||
series: supply.inflationRate.percent,
|
||||
name: "Base",
|
||||
color: colors.base,
|
||||
unit: Unit.percentage,
|
||||
}),
|
||||
...percentRatioDots({
|
||||
pattern: adjusted.inflationRate,
|
||||
name: "Cointime-Adjusted",
|
||||
color: colors.adjusted,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "BTC Velocity",
|
||||
title: "Cointime-Adjusted BTC Velocity",
|
||||
bottom: [
|
||||
line({
|
||||
series: supply.velocity.native,
|
||||
name: "Base",
|
||||
color: colors.base,
|
||||
series: activity.liveliness,
|
||||
name: "Liveliness",
|
||||
color: colors.liveliness,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
line({
|
||||
series: adjusted.txVelocityNative,
|
||||
name: "Cointime-Adjusted",
|
||||
color: colors.adjusted,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "USD Velocity",
|
||||
title: "Cointime-Adjusted USD Velocity",
|
||||
bottom: [
|
||||
line({
|
||||
series: supply.velocity.fiat,
|
||||
name: "Base",
|
||||
color: colors.thermo,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
line({
|
||||
series: adjusted.txVelocityFiat,
|
||||
name: "Cointime-Adjusted",
|
||||
series: activity.vaultedness,
|
||||
name: "Vaultedness",
|
||||
color: colors.vaulted,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
line({
|
||||
series: activity.ratio,
|
||||
name: "Liveliness / Vaultedness",
|
||||
color: colors.activity,
|
||||
unit: Unit.ratio,
|
||||
defaultActive: false,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Coinblocks",
|
||||
tree: [
|
||||
{
|
||||
name: "Compare",
|
||||
tree: multiSeriesTree({
|
||||
entries: coinblocks.map(({ pattern, name, color }) => ({
|
||||
name,
|
||||
color,
|
||||
average: pattern.average,
|
||||
sum: pattern.sum,
|
||||
cumulative: pattern.cumulative,
|
||||
})),
|
||||
metric: "Coinblocks",
|
||||
unit: Unit.coinblocks,
|
||||
}),
|
||||
},
|
||||
...coinblocks.map(
|
||||
({ pattern, name, title: metric, color }) => ({
|
||||
name,
|
||||
tree: sumsAndAveragesCumulative({
|
||||
sum: pattern.sum,
|
||||
average: pattern.average,
|
||||
cumulative: pattern.cumulative,
|
||||
metric,
|
||||
unit: Unit.coinblocks,
|
||||
color,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
],
|
||||
},
|
||||
createCointimeAgeRangeActivitySection(cointimeAgeRanges),
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
name: "Economics",
|
||||
tree: [
|
||||
{
|
||||
name: "Cointime Value",
|
||||
tree: [
|
||||
{
|
||||
name: "Compare",
|
||||
tree: multiSeriesTree({
|
||||
entries: [
|
||||
...cointimeValues.map(({ pattern, name, color }) => ({
|
||||
name,
|
||||
color,
|
||||
average: pattern.average,
|
||||
sum: pattern.sum,
|
||||
cumulative: pattern.cumulative,
|
||||
})),
|
||||
{
|
||||
name: vocdd.name,
|
||||
color: vocdd.color,
|
||||
average: vocdd.pattern.average,
|
||||
sum: vocdd.pattern.sum,
|
||||
cumulative: vocdd.pattern.cumulative,
|
||||
},
|
||||
],
|
||||
metric: "Cointime Value",
|
||||
unit: Unit.usd,
|
||||
}),
|
||||
},
|
||||
...cointimeValues.map(
|
||||
({ pattern, name, title: metric, color }) => ({
|
||||
name,
|
||||
tree: sumsAndAveragesCumulative({
|
||||
sum: pattern.sum,
|
||||
average: pattern.average,
|
||||
cumulative: pattern.cumulative,
|
||||
metric,
|
||||
unit: Unit.usd,
|
||||
color,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
{
|
||||
name: vocdd.name,
|
||||
tree: sumsAndAveragesCumulative({
|
||||
sum: vocdd.pattern.sum,
|
||||
average: vocdd.pattern.average,
|
||||
cumulative: vocdd.pattern.cumulative,
|
||||
metric: vocdd.title,
|
||||
unit: Unit.usd,
|
||||
color: vocdd.color,
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Indicators",
|
||||
tree: [
|
||||
{
|
||||
name: "AVIV",
|
||||
title: "AVIV Ratio",
|
||||
bottom: [
|
||||
line({
|
||||
series: cap.aviv.ratio,
|
||||
name: "AVIV",
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Reserve Risk",
|
||||
title: "Reserve Risk",
|
||||
bottom: [
|
||||
line({
|
||||
series: reserveRisk.value,
|
||||
name: "Ratio",
|
||||
color: colors.reserveRisk,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Adjustments",
|
||||
tree: [
|
||||
{
|
||||
name: "Inflation",
|
||||
title: "Cointime-Adjusted Inflation",
|
||||
bottom: [
|
||||
dots({
|
||||
series: supply.inflationRate.percent,
|
||||
name: "Base",
|
||||
color: colors.base,
|
||||
unit: Unit.percentage,
|
||||
}),
|
||||
...percentRatioDots({
|
||||
pattern: adjusted.inflationRate,
|
||||
name: "Cointime-Adjusted",
|
||||
color: colors.adjusted,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "BTC Velocity",
|
||||
title: "Cointime-Adjusted BTC Velocity",
|
||||
bottom: [
|
||||
line({
|
||||
series: supply.velocity.native,
|
||||
name: "Base",
|
||||
color: colors.base,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
line({
|
||||
series: adjusted.txVelocityNative,
|
||||
name: "Cointime-Adjusted",
|
||||
color: colors.adjusted,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "USD Velocity",
|
||||
title: "Cointime-Adjusted USD Velocity",
|
||||
bottom: [
|
||||
line({
|
||||
series: supply.velocity.fiat,
|
||||
name: "Base",
|
||||
color: colors.thermo,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
line({
|
||||
series: adjusted.txVelocityFiat,
|
||||
name: "Cointime-Adjusted",
|
||||
color: colors.vaulted,
|
||||
unit: Unit.ratio,
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -939,6 +939,11 @@ export function createMarketSection() {
|
||||
name: "True Market Mean",
|
||||
color: colors.trueMarketMean,
|
||||
}),
|
||||
price({
|
||||
series: cointime.awake.price,
|
||||
name: "Awake Price",
|
||||
color: colors.awake,
|
||||
}),
|
||||
price({
|
||||
series: coinflow.price,
|
||||
name: "Coinflow Price",
|
||||
|
||||
@@ -34,10 +34,9 @@ import {
|
||||
oraclePaymentsHeatmapOption,
|
||||
} from "../../src/heatmap/oracle.js";
|
||||
import {
|
||||
urpdAgeBandHeatmapFolders,
|
||||
urpdAllHeatmapOptions,
|
||||
urpdLthHeatmapOptions,
|
||||
urpdSthHeatmapOptions,
|
||||
coinflowWeightedUrpdHeatmapTree,
|
||||
cointimeWeightedUrpdHeatmapTree,
|
||||
rawUrpdHeatmapTree,
|
||||
} from "../../src/heatmap/urpd.js";
|
||||
|
||||
// Re-export types for external consumers
|
||||
@@ -343,10 +342,15 @@ export function createPartialOptions() {
|
||||
{
|
||||
name: "Price Distributions",
|
||||
tree: [
|
||||
...urpdAllHeatmapOptions,
|
||||
{ name: "STH", tree: urpdSthHeatmapOptions },
|
||||
{ name: "LTH", tree: urpdLthHeatmapOptions },
|
||||
{ name: "Age Bands", tree: urpdAgeBandHeatmapFolders },
|
||||
...rawUrpdHeatmapTree,
|
||||
{
|
||||
name: "Cointime Weighted",
|
||||
tree: cointimeWeightedUrpdHeatmapTree,
|
||||
},
|
||||
{
|
||||
name: "Coinflow Weighted",
|
||||
tree: coinflowWeightedUrpdHeatmapTree,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
+81
-20
@@ -113,38 +113,89 @@ const AGE_BANDS = [
|
||||
{ name: "Over 15y", cohort: "utxos_over_15y_old" },
|
||||
];
|
||||
|
||||
export const urpdAllHeatmapOptions = createCohortHeatmapOptions({
|
||||
cohort: "all",
|
||||
export const rawUrpdHeatmapTree = createUrpdHeatmapTree();
|
||||
export const cointimeWeightedUrpdHeatmapTree = createUrpdHeatmapTree({
|
||||
weight: "cointime",
|
||||
weightTitle: "Cointime-Weighted",
|
||||
});
|
||||
export const urpdSthHeatmapOptions = createCohortHeatmapOptions({
|
||||
cohort: "sth",
|
||||
titlePrefix: "STH",
|
||||
export const coinflowWeightedUrpdHeatmapTree = createUrpdHeatmapTree({
|
||||
weight: "coinflow",
|
||||
weightTitle: "Coinflow-Weighted",
|
||||
});
|
||||
export const urpdLthHeatmapOptions = createCohortHeatmapOptions({
|
||||
cohort: "lth",
|
||||
titlePrefix: "LTH",
|
||||
});
|
||||
export const urpdAgeBandHeatmapFolders = AGE_BANDS.map(({ name, cohort }) => ({
|
||||
name,
|
||||
tree: createCohortHeatmapOptions({ cohort, titlePrefix: name }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {UrpdWeight} [args.weight]
|
||||
* @param {string} [args.weightTitle]
|
||||
* @returns {PartialOptionsTree}
|
||||
*/
|
||||
function createUrpdHeatmapTree({ weight, weightTitle } = {}) {
|
||||
return [
|
||||
...createCohortHeatmapOptions({ cohort: "all", weight, weightTitle }),
|
||||
{
|
||||
name: "STH",
|
||||
tree: createCohortHeatmapOptions({
|
||||
cohort: "sth",
|
||||
titlePrefix: "STH",
|
||||
weight,
|
||||
weightTitle,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "LTH",
|
||||
tree: createCohortHeatmapOptions({
|
||||
cohort: "lth",
|
||||
titlePrefix: "LTH",
|
||||
weight,
|
||||
weightTitle,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "Age Bands",
|
||||
tree: AGE_BANDS.map(({ name, cohort }) => ({
|
||||
name,
|
||||
tree: createCohortHeatmapOptions({
|
||||
cohort,
|
||||
titlePrefix: name,
|
||||
weight,
|
||||
weightTitle,
|
||||
}),
|
||||
})),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} args
|
||||
* @param {UrpdCohort} args.cohort
|
||||
* @param {string} [args.titlePrefix]
|
||||
* @param {UrpdWeight} [args.weight]
|
||||
* @param {string} [args.weightTitle]
|
||||
* @returns {PartialHeatmapOption[]}
|
||||
*/
|
||||
function createCohortHeatmapOptions({ cohort, titlePrefix }) {
|
||||
function createCohortHeatmapOptions({
|
||||
cohort,
|
||||
titlePrefix,
|
||||
weight,
|
||||
weightTitle,
|
||||
}) {
|
||||
return METRICS.map((metric) => {
|
||||
const title = titlePrefix
|
||||
? `${titlePrefix} ${metric.title} Distribution`
|
||||
: `${metric.title} Distribution`;
|
||||
const title = [weightTitle, titlePrefix, metric.title, "Distribution"]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const tooltip = weightTitle
|
||||
? {
|
||||
...metric.tooltip,
|
||||
valueLabel: `${weightTitle} ${metric.tooltip.valueLabel.toLowerCase()}`,
|
||||
}
|
||||
: metric.tooltip;
|
||||
|
||||
return createUrpdHeatmapOption({
|
||||
...metric,
|
||||
cohort,
|
||||
title,
|
||||
tooltip,
|
||||
weight,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -154,6 +205,7 @@ function createCohortHeatmapOptions({ cohort, titlePrefix }) {
|
||||
* @param {UrpdCohort} args.cohort
|
||||
* @param {string} args.name
|
||||
* @param {string} args.title
|
||||
* @param {UrpdWeight} [args.weight]
|
||||
* @param {(bucket: Urpd["buckets"][number]) => number} args.getValue
|
||||
* @param {HeatmapColorFn} args.color
|
||||
* @param {{ valueLabel?: string, formatValue?: (value: number) => string }} args.tooltip
|
||||
@@ -163,6 +215,7 @@ function createUrpdHeatmapOption({
|
||||
cohort,
|
||||
name,
|
||||
title,
|
||||
weight,
|
||||
getValue,
|
||||
color,
|
||||
tooltip,
|
||||
@@ -173,7 +226,7 @@ function createUrpdHeatmapOption({
|
||||
title,
|
||||
points: {
|
||||
fetch: (date, signal, onPoints) =>
|
||||
fetchUrpdPoints(cohort, date, signal, getValue, onPoints),
|
||||
fetchUrpdPoints(cohort, date, weight, signal, getValue, onPoints),
|
||||
},
|
||||
grid: createAverageGrid({
|
||||
yMin: MIN_LOG,
|
||||
@@ -201,19 +254,27 @@ function createUrpdHeatmapOption({
|
||||
/**
|
||||
* @param {UrpdCohort} cohort
|
||||
* @param {string} date
|
||||
* @param {UrpdWeight} [weight]
|
||||
* @param {AbortSignal} signal
|
||||
* @param {(bucket: Urpd["buckets"][number]) => number} getValue
|
||||
* @param {(points: HeatmapPoints) => void} [onPoints]
|
||||
* @returns {Promise<HeatmapPoints>}
|
||||
*/
|
||||
async function fetchUrpdPoints(cohort, date, signal, getValue, onPoints) {
|
||||
async function fetchUrpdPoints(
|
||||
cohort,
|
||||
date,
|
||||
weight,
|
||||
signal,
|
||||
getValue,
|
||||
onPoints,
|
||||
) {
|
||||
/** @type {HeatmapPoints | undefined} */
|
||||
let points;
|
||||
const urpd = await brk.getUrpdAt(
|
||||
cohort,
|
||||
date,
|
||||
AGGREGATION,
|
||||
undefined,
|
||||
weight,
|
||||
{
|
||||
signal,
|
||||
onValue: onPoints
|
||||
|
||||
+17
-17
@@ -17,11 +17,11 @@ For machine-readable tool construction, use [https://bitview.space/openapi.json]
|
||||
|
||||
Address hash-prefix matches
|
||||
|
||||
Find addresses by address type and by the first 1-16 hex nibbles of RapidHash v3 over the raw address payload bytes. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata for the returned addresses through `/api/address/{address}`.
|
||||
Find addresses by address type and by the first 1-16 hex nibbles of RapidHash v3 over the raw address payload bytes. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata with `GET /api/address/{address}`.
|
||||
|
||||
Parameters:
|
||||
- `addr_type` (path, OutputType, required)
|
||||
- `prefix` (path, string, required)
|
||||
- `prefix` (path, string, required): First 1–16 hexadecimal nibbles of the RapidHash v3 hash over the raw address payload bytes.
|
||||
|
||||
Returns: JSON `AddrHashPrefixMatches`
|
||||
|
||||
@@ -48,7 +48,7 @@ curl -s "https://bitview.space/api/address/<address>"
|
||||
|
||||
Address transactions
|
||||
|
||||
Get transaction history for an address, newest first. Returns up to 50 mempool transactions plus a confirmed page sized to fill the response to 50 total (chain floor of 25, so 25-50 confirmed depending on mempool weight). To paginate further confirmed history, use `/address/{address}/txs/chain/{last_seen_txid}`. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*
|
||||
Get transaction history for an address, newest first. Returns up to 50 mempool transactions plus a confirmed page sized to fill the response to 50 total (chain floor of 25, so 25-50 confirmed depending on mempool weight). To paginate further confirmed history, request `GET /api/address/{address}/txs/chain/{after_txid}` with the last returned txid. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions)*
|
||||
|
||||
Parameters:
|
||||
- `address` (path, Addr, required)
|
||||
@@ -63,7 +63,7 @@ curl -s "https://bitview.space/api/address/<address>/txs"
|
||||
|
||||
Address confirmed transactions
|
||||
|
||||
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)*
|
||||
Get the first 25 confirmed transactions for an address. For pagination, request `GET /api/address/{address}/txs/chain/{after_txid}` with the last returned txid. *[Mempool.space docs](https://mempool.space/docs/api/rest#get-address-transactions-chain)*
|
||||
|
||||
Parameters:
|
||||
- `address` (path, Addr, required)
|
||||
@@ -126,7 +126,7 @@ curl -s "https://bitview.space/api/address/<address>/utxo"
|
||||
|
||||
Compact OpenAPI specification
|
||||
|
||||
Compact OpenAPI specification optimized for LLM consumption. Removes redundant fields while preserving essential API information. Full spec available at `/openapi.json`.
|
||||
Compact OpenAPI specification optimized for LLM consumption. Removes redundant fields while preserving essential API information. The full specification is available at `GET /openapi.json`.
|
||||
|
||||
Returns: JSON `*`
|
||||
|
||||
@@ -445,7 +445,7 @@ curl -s "https://bitview.space/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)*
|
||||
Same response shape as `GET /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)*
|
||||
|
||||
Returns: JSON `ReplacementNode[]`
|
||||
|
||||
@@ -459,7 +459,7 @@ curl -s "https://bitview.space/api/v1/fullrbf/replacements"
|
||||
|
||||
Health check
|
||||
|
||||
Liveness probe. Returns server identity, uptime, and indexed/computed heights from local state only (no bitcoind round-trip). For real chain-tip catch-up, see `/api/server/sync`.
|
||||
Liveness probe. Returns server identity, uptime, and indexed/computed heights from local state only (no bitcoind round-trip). For real chain-tip catch-up, request `GET /api/server/sync`.
|
||||
|
||||
Returns: JSON `Health`
|
||||
|
||||
@@ -550,7 +550,7 @@ curl -s "https://bitview.space/api/mempool/txids"
|
||||
|
||||
Projected next block template
|
||||
|
||||
Bitcoin Core's `getblocktemplate` selection: full transaction bodies in GBT order with aggregate stats. The returned `hash` is an opaque content token; pass it as `<hash>` on `/api/v1/mempool/block-template/diff/{hash}` to fetch deltas instead of refetching the whole template.
|
||||
Bitcoin Core's `getblocktemplate` selection: full transaction bodies in GBT order with aggregate stats. The returned `hash` is an opaque content token; pass it to `GET /api/v1/mempool/block-template/diff/{hash}` to fetch deltas instead of refetching the whole template.
|
||||
|
||||
Returns: JSON `BlockTemplate`
|
||||
|
||||
@@ -562,7 +562,7 @@ curl -s "https://bitview.space/api/v1/mempool/block-template"
|
||||
|
||||
Block template diff since hash
|
||||
|
||||
Delta of the projected next block since `<hash>`. `order` is the full new template in order: each entry is either a number (index into the prior template the client cached at `<hash>`) or a transaction object (new body to insert at this position). Walk `order` once to rebuild; `removed` is a convenience list of txids that left so clients can evict cached bodies. After applying, use the response `hash` as `<hash>` on the next call to keep iterating. Returns `404` when `<hash>` has aged out of server history; clients should fall back to `/api/v1/mempool/block-template`.
|
||||
Delta of the projected next block since `<hash>`. `order` is the full new template in order: each entry is either a number (index into the prior template the client cached at `<hash>`) or a transaction object (new body to insert at this position). Walk `order` once to rebuild; `removed` is a convenience list of txids that left so clients can evict cached bodies. After applying, use the response `hash` as `<hash>` on the next call to keep iterating. Returns `404` when `<hash>` has aged out of server history; clients should fall back to `GET /api/v1/mempool/block-template`.
|
||||
|
||||
Parameters:
|
||||
- `hash` (path, NextBlockHash, required)
|
||||
@@ -869,7 +869,7 @@ Output value histogram at height or day
|
||||
Unfiltered output value histogram for a confirmed point. A block height (`840000`) gives every output in that block, coinbase included, binned by value on the oracle log scale; a calendar date (`YYYY-MM-DD`) sums every block that day. A flat array of log-scale bins.
|
||||
|
||||
Parameters:
|
||||
- `point` (path, string, required)
|
||||
- `point` (path, string, required): Confirmed block height as decimal digits (`840000`) or calendar date in `YYYY-MM-DD` format.
|
||||
|
||||
Returns: JSON `integer[]`
|
||||
|
||||
@@ -896,7 +896,7 @@ Payment output histogram at height or day
|
||||
Smoothed histogram of oracle-eligible payment outputs for a confirmed point. A block height (`840000`) gives that block's oracle payment histogram; a calendar date (`YYYY-MM-DD`) gives the average of that day's per-block payment histograms. A flat array of log-scale bins.
|
||||
|
||||
Parameters:
|
||||
- `point` (path, string, required)
|
||||
- `point` (path, string, required): Confirmed block height as decimal digits (`840000`) or calendar date in `YYYY-MM-DD` format.
|
||||
|
||||
Returns: JSON `integer[]`
|
||||
|
||||
@@ -908,7 +908,7 @@ curl -s "https://bitview.space/api/oracle/histogram/payments/<point>"
|
||||
|
||||
Live BTC/USD price
|
||||
|
||||
Current BTC/USD price in dollars. Same value as `/api/mempool/price`. Confirmed per-height history is available at `/api/vecs/height-to-price`.
|
||||
Current BTC/USD price in dollars. Same value as `GET /api/mempool/price`. Confirmed per-height history is available at `GET /api/series/price/height`.
|
||||
|
||||
Returns: JSON `Dollars`
|
||||
|
||||
@@ -1073,7 +1073,7 @@ curl -s "https://bitview.space/api/series/<series>/<index>?start=<start>&end=<en
|
||||
|
||||
Get raw series data
|
||||
|
||||
Returns just the data array without the SeriesData wrapper. Supports the same range and format parameters as the standard endpoint.
|
||||
Returns just the data array without the SeriesData wrapper. Supports the same range and format parameters as `GET /api/series/{series}/{index}`.
|
||||
|
||||
Parameters:
|
||||
- `series` (path, SeriesName, required): Series name
|
||||
@@ -1367,7 +1367,7 @@ curl -s "https://bitview.space/api/urpd"
|
||||
|
||||
Latest URPD
|
||||
|
||||
URPD for the most recent available date in the cohort. The response's `date` field echoes which date was served. See the URPD tag description for the response shape, `agg`, and `weight` options.
|
||||
URPD for the most recent available date in the cohort. The response's `date` field echoes which date was served. Returns `{ cohort, date, weight, aggregation, close, total_supply, buckets }`. `close` and each bucket's `price_floor`, `realized_cap`, and `unrealized_pnl` are USD; `total_supply` and bucket `supply` are BTC. `unrealized_pnl` can be negative.
|
||||
|
||||
Parameters:
|
||||
- `cohort` (path, Cohort, required)
|
||||
@@ -1400,11 +1400,11 @@ curl -s "https://bitview.space/api/urpd/<cohort>/dates?weight=<weight>"
|
||||
|
||||
URPD at date
|
||||
|
||||
URPD for a (cohort, date) pair. Returns `{ cohort, date, weight, aggregation, close, total_supply, buckets }` where each bucket is `{ price_floor, supply, realized_cap, unrealized_pnl }`. See the URPD tag description for unit conventions, `agg`, and `weight` options.
|
||||
URPD for a (cohort, date) pair. Returns `{ cohort, date, weight, aggregation, close, total_supply, buckets }` where each bucket is `{ price_floor, supply, realized_cap, unrealized_pnl }`. `close`, `price_floor`, `realized_cap`, and `unrealized_pnl` are USD; `total_supply` and `supply` are BTC. `unrealized_pnl` can be negative.
|
||||
|
||||
Parameters:
|
||||
- `cohort` (path, Cohort, required)
|
||||
- `date` (path, string, required)
|
||||
- `date` (path, string, required): Calendar date of the URPD snapshot in `YYYY-MM-DD` format.
|
||||
- `agg` (query, UrpdAggregation, optional): Aggregation strategy. Default: raw (no aggregation). Accepts `bucket` as alias.
|
||||
- `weight` (query, UrpdWeight, optional): Supply weighting. Default: raw (unweighted).
|
||||
|
||||
@@ -1599,7 +1599,7 @@ curl -s "https://bitview.space/version"
|
||||
|
||||
### `BlockTemplate`
|
||||
|
||||
- `hash`: `NextBlockHash` (required) — Pass back as `<hash>` on `/api/v1/mempool/block-template/diff/{hash}` to fetch deltas.
|
||||
- `hash`: `NextBlockHash` (required) — Pass to `GET /api/v1/mempool/block-template/diff/{hash}` to fetch deltas.
|
||||
- `stats`: `MempoolBlock` (required) — Aggregate stats for this block (size, vsize, fee range, ...).
|
||||
- `transactions`: `Transaction[]` (required) — Full transaction bodies in `getblocktemplate` order.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user