mcp: add back

This commit is contained in:
nym21
2026-08-05 14:50:47 +02:00
parent a4bb76c784
commit c4decdef77
55 changed files with 19950 additions and 1297 deletions
Generated
+146
View File
@@ -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(&parameter.param_type)
} else {
parameter.schema.clone()
};
if let Some(description) = &parameter.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,
}
+17 -1
View File
@@ -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)
+24
View File
@@ -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,
}
+12 -16
View File
@@ -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
View File
@@ -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);
}
}
+12 -4
View File
@@ -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!(
+23
View File
@@ -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"
+54
View File
@@ -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
+77
View File
@@ -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"
);
}
}
+58
View File
@@ -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",
)
}))
}
+550
View File
@@ -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(&parameter.name) {
return Err(format!(
"tool {} repeats parameter {}",
operation.tool.name, parameter.name
));
}
if !properties.contains_key(&parameter.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()
);
}
}
+343
View File
@@ -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
}
+262
View File
@@ -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(&parameter.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"));
}
}
+3 -3
View File
@@ -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()
+3 -3
View File
@@ -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()
+1 -1
View File
@@ -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>()
},
+1 -1
View File
@@ -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()
+2 -2
View File
@@ -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()
+2 -1
View File
@@ -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()
+1 -1
View File
@@ -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>()
},
),
+8 -6
View File
@@ -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()
+4 -1
View File
@@ -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 116 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,
}
+1 -2
View File
@@ -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, ...).
+1 -2
View File
@@ -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
View File
@@ -40,7 +40,8 @@
/**
* @typedef {Object} AddrHashPrefixParam
* @property {OutputType} addrType
* @property {string} prefix
* @property {string} prefix - First 116 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 116 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]
+27 -21
View File
@@ -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 116 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')
+27 -27
View File
@@ -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
View File
@@ -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 116 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.
+153 -119
View File
@@ -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,
}),
],
},
],
},
],
+5
View File
@@ -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",
+12 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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 116 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.