server: api + doc

This commit is contained in:
nym21
2025-10-09 17:24:44 +02:00
parent 6ad15221de
commit 1821d5d57b
38 changed files with 952 additions and 865 deletions

View File

@@ -1,239 +1,16 @@
use std::str::FromStr;
use aide::{
axum::{ApiRouter, routing::get_with},
transform::TransformOperation,
};
use aide::axum::{ApiRouter, routing::get_with};
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
response::Response,
};
use bitcoin::{Address as BitcoinAddress, Network, PublicKey, ScriptBuf};
use brk_structs::{
AddressBytes, AddressBytesHash, AnyAddressDataIndexEnum, Bitcoin, Dollars, OutputType, Sats,
TypeIndex,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use vecdb::{AnyIterableVec, VecIterator};
use brk_structs::{AddressInfo, AddressPath};
use crate::extended::TransformResponseExtended;
use crate::extended::{ResponseExtended, ResultExtended, TransformResponseExtended};
use super::AppState;
#[derive(Debug, Serialize, JsonSchema)]
/// Address information
struct AddressInfo {
/// Bitcoin address string
#[schemars(example = &"04678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5f")]
address: String,
#[schemars(example = OutputType::P2PK65)]
r#type: OutputType,
#[schemars(example = TypeIndex::new(0))]
type_index: TypeIndex,
/// Total satoshis ever sent from this address
#[schemars(example = Sats::new(0))]
total_sent: Sats,
/// Total satoshis ever received by this address
#[schemars(example = Sats::new(5001008380))]
total_received: Sats,
/// Number of unspent transaction outputs (UTXOs)
#[schemars(example = 10)]
utxo_count: u32,
/// Current spendable balance in satoshis (total_received - total_sent)
#[schemars(example = Sats::new(5001008380))]
balance: Sats,
/// Current balance value in USD at current market price
#[schemars(example = Some(Dollars::mint(6_157_891.64)))]
balance_usd: Option<Dollars>,
/// Estimated total USD value at time of deposit for coins currently in this address (not including coins that were later sent out). Not suitable for tax calculations
#[schemars(example = Some(Dollars::mint(6.2)))]
estimated_total_invested: Option<Dollars>,
/// Estimated average BTC price at time of deposit for coins currently in this address (USD). Not suitable for tax calculations
#[schemars(example = Some(Dollars::mint(0.12)))]
estimated_avg_entry_price: Option<Dollars>,
//
// Transaction count?
// First/last activity timestamps?
// Realized/unrealized gains?
// Current value (balance × current price)?
// "address": address,
// "type": output_type,
// "index": addri,
// "chain_stats": {
// "funded_txo_count": null,
// "funded_txo_sum": addr_data.received,
// "spent_txo_count": null,
// "spent_txo_sum": addr_data.sent,
// "utxo_count": addr_data.utxos,
// "balance": amount,
// "balance_usd": price.map_or(Value::new(), |p| {
// Value::from(Number::from_f64(*(p * Bitcoin::from(amount))).unwrap())
// }),
// "realized_value": addr_data.realized_cap,
// "tx_count": null,
// "avg_cost_basis": addr_data.realized_price()
// },
// "mempool_stats": null
}
#[derive(Deserialize, JsonSchema)]
struct AddressPath {
/// Bitcoin address string
#[schemars(example = &"04678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5f")]
address: String,
}
async fn get_address_info(
Path(AddressPath { address }): Path<AddressPath>,
state: State<AppState>,
) -> Result<Json<AddressInfo>, (StatusCode, Json<&'static str>)> {
let interface = state.interface;
let indexer = interface.indexer();
let computer = interface.computer();
let stores = &indexer.stores;
let script = if let Ok(address) = BitcoinAddress::from_str(&address) {
if !address.is_valid_for_network(Network::Bitcoin) {
return Err((
StatusCode::BAD_REQUEST,
Json("The provided address isn't the Bitcoin Network."),
));
}
let address = address.assume_checked();
address.script_pubkey()
} else if let Ok(pubkey) = PublicKey::from_str(&address) {
ScriptBuf::new_p2pk(&pubkey)
} else {
return Err((
StatusCode::BAD_REQUEST,
Json("The provided address is invalid."),
));
};
let type_ = OutputType::from(&script);
let Ok(bytes) = AddressBytes::try_from((&script, type_)) else {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json("Failed to convert the address to bytes"),
));
};
let hash = AddressBytesHash::from((&bytes, type_));
let Ok(Some(type_index)) = stores
.addressbyteshash_to_typeindex
.get(&hash)
.map(|opt| opt.map(|cow| cow.into_owned()))
else {
return Err((
StatusCode::NOT_FOUND,
Json("Address not found in the blockchain (no transaction history)"),
));
};
let stateful = &computer.stateful;
let price = computer.price.as_ref().map(|v| {
*v.timeindexes_to_price_close
.dateindex
.as_ref()
.unwrap()
.iter()
.last()
.unwrap()
.1
.into_owned()
});
let any_address_index = match type_ {
OutputType::P2PK33 => stateful
.p2pk33addressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2PK65 => stateful
.p2pk65addressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2PKH => stateful
.p2pkhaddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2SH => stateful
.p2shaddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2TR => stateful
.p2traddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2WPKH => stateful
.p2wpkhaddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2WSH => stateful
.p2wshaddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
OutputType::P2A => stateful
.p2aaddressindex_to_anyaddressindex
.iter()
.unwrap_get_inner(type_index.into()),
_ => {
return Err((
StatusCode::BAD_REQUEST,
Json("The provided address uses an unsupported type"),
));
}
};
let address_data = match any_address_index.to_enum() {
AnyAddressDataIndexEnum::Loaded(index) => stateful
.loadedaddressindex_to_loadedaddressdata
.iter()
.unwrap_get_inner(index),
AnyAddressDataIndexEnum::Empty(index) => stateful
.emptyaddressindex_to_emptyaddressdata
.iter()
.unwrap_get_inner(index)
.into(),
};
let balance = address_data.balance();
Ok(Json(AddressInfo {
address: address.to_string(),
r#type: type_,
type_index,
utxo_count: address_data.utxo_count,
total_sent: address_data.sent,
total_received: address_data.received,
balance,
balance_usd: price.map(|p| p * Bitcoin::from(balance)),
estimated_total_invested: price.map(|_| address_data.realized_cap),
estimated_avg_entry_price: price.map(|_| address_data.realized_price()),
}))
}
fn get_address_info_docs(op: TransformOperation) -> TransformOperation {
op.tag("Chain")
.summary("Address information")
.description("Retrieve comprehensive information about a Bitcoin address including balance, transaction history, UTXOs, and estimated investment metrics. Supports all standard Bitcoin address types (P2PKH, P2SH, P2WPKH, P2WSH, P2TR, etc.).")
.with_ok_response::<AddressInfo, _>(|res| res)
.with_not_modified()
.with_bad_request()
.with_not_found()
.with_server_error()
}
pub trait AddressesRoutes {
fn add_addresses_routes(self) -> Self;
}
@@ -242,7 +19,24 @@ impl AddressesRoutes for ApiRouter<AppState> {
fn add_addresses_routes(self) -> Self {
self.api_route(
"/api/chain/address/{address}",
get_with(get_address_info, get_address_info_docs),
get_with(async |Path(address): Path<AddressPath>,
State(app_state): State<AppState>|
-> Result<Response, (StatusCode, Json<String>)> {
let address_info = app_state.interface.get_address_info(address).to_server_result()?;
let bytes = sonic_rs::to_vec(&address_info).unwrap();
Ok(Response::new_json_from_bytes(bytes))
}, |op| op
.tag("Chain")
.summary("Address information")
.description("Retrieve comprehensive information about a Bitcoin address including balance, transaction history, UTXOs, and estimated investment metrics. Supports all standard Bitcoin address types (P2PKH, P2SH, P2WPKH, P2WSH, P2TR, etc.).")
.with_ok_response::<AddressInfo, _>(|res| res)
.with_not_modified()
.with_bad_request()
.with_not_found()
.with_server_error()
),
)
}
}

View File

@@ -1,161 +1,16 @@
use std::{
fs::File,
io::{Cursor, Read, Seek, SeekFrom},
str::FromStr,
};
use aide::{
axum::{ApiRouter, routing::get_with},
transform::TransformOperation,
};
use aide::axum::{ApiRouter, routing::get_with};
use axum::{
Json,
extract::{Path, State},
http::StatusCode,
response::Response,
};
use bitcoin::{Transaction as BitcoinTransaction, consensus::Decodable};
use brk_parser::XORIndex;
use brk_structs::{TxIndex, Txid, TxidPrefix};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use vecdb::VecIterator;
use brk_structs::{TransactionInfo, TxidPath};
use crate::extended::{ResponseExtended, TransformResponseExtended};
use crate::extended::{ResponseExtended, ResultExtended, TransformResponseExtended};
use super::AppState;
#[derive(Serialize, JsonSchema)]
/// Transaction Information
struct TransactionInfo {
#[schemars(
with = "String",
example = "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b"
)]
txid: Txid,
#[schemars(example = TxIndex::new(0))]
index: TxIndex,
#[serde(flatten)]
#[schemars(with = "serde_json::Value")]
tx: BitcoinTransaction,
}
#[derive(Deserialize, JsonSchema)]
struct TxidPath {
/// Bitcoin transaction id
#[schemars(example = &"4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b")]
txid: String,
}
async fn get_transaction_info(
Path(TxidPath { txid }): Path<TxidPath>,
state: State<AppState>,
) -> Result<Response, (StatusCode, Json<&'static str>)> {
let Ok(txid) = bitcoin::Txid::from_str(&txid) else {
return Err((
StatusCode::BAD_REQUEST,
Json("The provided TXID appears to be invalid."),
));
};
let txid = Txid::from(txid);
let prefix = TxidPrefix::from(&txid);
let interface = state.interface;
let indexer = interface.indexer();
let Ok(Some(index)) = indexer
.stores
.txidprefix_to_txindex
.get(&prefix)
.map(|opt| opt.map(|cow| cow.into_owned()))
else {
return Err((
StatusCode::NOT_FOUND,
Json("Failed to found the TXID in the blockchain."),
));
};
let txid = indexer.vecs.txindex_to_txid.iter().unwrap_get_inner(index);
let parser = interface.parser();
let computer = interface.computer();
let position = computer
.blks
.txindex_to_position
.iter()
.unwrap_get_inner(index);
let len = indexer
.vecs
.txindex_to_total_size
.iter()
.unwrap_get_inner(index);
let blk_index_to_blk_path = parser.blk_index_to_blk_path();
let Some(blk_path) = blk_index_to_blk_path.get(&position.blk_index()) else {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json("Failed to read the transaction (get blk's path)"),
));
};
let mut xori = XORIndex::default();
xori.add_assign(position.offset() as usize);
let Ok(mut file) = File::open(blk_path) else {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json("Failed to read the transaction (open file)"),
));
};
if file
.seek(SeekFrom::Start(position.offset() as u64))
.is_err()
{
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json("Failed to read the transaction (file seek)"),
));
}
let mut buffer = vec![0u8; *len as usize];
if file.read_exact(&mut buffer).is_err() {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json("Failed to read the transaction (read exact)"),
));
}
xori.bytes(&mut buffer, parser.xor_bytes());
let mut reader = Cursor::new(buffer);
let Ok(tx) = BitcoinTransaction::consensus_decode(&mut reader) else {
return Err((
StatusCode::INTERNAL_SERVER_ERROR,
Json("Failed decode the transaction"),
));
};
let tx_info = TransactionInfo { txid, index, tx };
let bytes = sonic_rs::to_vec(&tx_info).unwrap();
Ok(Response::new_json_from_bytes(bytes))
}
fn get_transaction_info_docs(op: TransformOperation) -> TransformOperation {
op.tag("Chain")
.summary("Transaction information")
.description(
"Retrieve complete transaction data by transaction ID (txid). Returns the full transaction details including inputs, outputs, and metadata. The transaction data is read directly from the blockchain data files.",
)
.with_ok_response::<TransactionInfo, _>(|res| res)
.with_not_modified()
.with_bad_request()
.with_not_found()
.with_server_error()
}
pub trait TransactionsRoutes {
fn add_transactions_routes(self) -> Self;
}
@@ -164,7 +19,28 @@ impl TransactionsRoutes for ApiRouter<AppState> {
fn add_transactions_routes(self) -> Self {
self.api_route(
"/api/chain/tx/{txid}",
get_with(get_transaction_info, get_transaction_info_docs),
get_with(
async |Path(txid): Path<TxidPath>,
State(app_state): State<AppState>|
-> Result<Response, (StatusCode, Json<String>)> {
let tx_info = app_state.interface.get_transaction_info(txid).to_server_result()?;
let bytes = sonic_rs::to_vec(&tx_info).unwrap();
Ok(Response::new_json_from_bytes(bytes))
},
|op| op
.tag("Chain")
.summary("Transaction information")
.description(
"Retrieve complete transaction data by transaction ID (txid). Returns the full transaction details including inputs, outputs, and metadata. The transaction data is read directly from the blockchain data files.",
)
.with_ok_response::<TransactionInfo, _>(|res| res)
.with_not_modified()
.with_bad_request()
.with_not_found()
.with_server_error(),
),
)
}
}

View File

@@ -8,7 +8,8 @@ use axum::{
response::{IntoResponse, Response},
};
use brk_error::{Error, Result};
use brk_interface::{Format, Output, Params};
use brk_interface::{Output, Params};
use brk_structs::Format;
use quick_cache::sync::GuardResult;
use vecdb::Stamp;

View File

@@ -6,13 +6,9 @@ use axum::{
response::{IntoResponse, Redirect, Response},
routing::get,
};
use brk_interface::{
MetricCount, PaginatedMetrics, PaginationParam, Params, ParamsDeprec, ParamsOpt,
};
use brk_structs::{Index, IndexInfo};
use brk_interface::{PaginatedMetrics, PaginationParam, Params, ParamsDeprec, ParamsOpt};
use brk_structs::{Index, IndexInfo, MetricCount, MetricPath};
use brk_traversable::TreeNode;
use schemars::JsonSchema;
use serde::Deserialize;
use crate::{
VERSION,
@@ -27,13 +23,6 @@ pub trait ApiMetricsRoutes {
fn add_metrics_routes(self) -> Self;
}
#[derive(Deserialize, JsonSchema)]
struct MetricPath {
/// Metric name
#[schemars(example = &"price_close", example = &"market_cap", example = &"realized_price")]
metric: String,
}
const TO_SEPARATOR: &str = "_to_";
impl ApiMetricsRoutes for ApiRouter<AppState> {

View File

@@ -10,8 +10,7 @@ use axum::{
response::{Html, Redirect, Response},
routing::get,
};
use schemars::JsonSchema;
use serde::Serialize;
use brk_structs::Health;
use crate::{
VERSION,
@@ -31,14 +30,6 @@ pub trait ApiRoutes {
fn add_api_routes(self) -> Self;
}
#[derive(Debug, Serialize, JsonSchema)]
/// Server health status
struct Health {
status: String,
service: String,
timestamp: String,
}
impl ApiRoutes for ApiRouter<AppState> {
fn add_api_routes(self) -> Self {
self.add_chain_routes()
@@ -62,7 +53,7 @@ impl ApiRoutes for ApiRouter<AppState> {
async || -> Json<Health> {
Json(Health {
status: "healthy".to_string(),
service: "brk-server".to_string(),
service: "brk".to_string(),
timestamp: jiff::Timestamp::now().to_string(),
})
},
@@ -101,5 +92,9 @@ impl ApiRoutes for ApiRouter<AppState> {
),
)
.route("/api", get(Html::from(include_str!("./scalar.html"))))
.route(
"/api/{*path}",
get(|| async { Redirect::permanent("/api") }),
)
}
}

View File

@@ -15,6 +15,17 @@ use aide::openapi::{Info, OpenApi, Tag};
use crate::VERSION;
pub fn create_openapi() -> OpenApi {
let info = Info {
title: "Bitcoin Research Kit".to_string(),
description: Some(
"API for querying Bitcoin blockchain data including addresses, transactions, and chain statistics. This API provides low-level access to indexed blockchain data with advanced analytics capabilities.\n\n\
⚠️ **Early Development**: This API is in very early stages of development. Breaking changes may occur without notice. For a more stable experience, [self-host](/install) or use the [hosting service](/service)."
.to_string(),
),
version: format!("v{VERSION}"),
..Info::default()
};
let tags = vec![
Tag {
name: "Chain".to_string(),
@@ -44,16 +55,7 @@ pub fn create_openapi() -> OpenApi {
];
OpenApi {
info: Info {
title: "Bitcoin Research Kit API".to_string(),
description: Some(
"API for querying Bitcoin blockchain data including addresses, transactions, and chain statistics. This API provides low-level access to indexed blockchain data with advanced analytics capabilities.\n\n\
⚠️ **Early Development**: This API is in very early stages of development. Breaking changes may occur without notice. For a more stable experience, self-host or use the hosting service."
.to_string(),
),
version: format!("v{VERSION}"),
..Info::default()
},
info,
tags,
..OpenApi::default()
}