diff --git a/Cargo.lock b/Cargo.lock index b05061041..bc0bfdb7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -514,6 +514,27 @@ dependencies = [ "brk_structs", ] +[[package]] +name = "brk-aide" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b25c11ed19c06e037fb0f6ab12f218ec79ed53c7daa7a5b55865112e2320097f" +dependencies = [ + "axum", + "bytes", + "cfg-if", + "http", + "indexmap 2.11.4", + "schemars 1.0.4", + "serde", + "serde_json", + "serde_qs", + "thiserror 2.0.17", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "brk-file-id" version = "0.2.3" @@ -710,7 +731,7 @@ dependencies = [ name = "brk_mcp" version = "0.0.111" dependencies = [ - "axum", + "brk-aide", "brk_interface", "brk_rmcp", "log", @@ -1132,6 +1153,7 @@ dependencies = [ "axum", "bitcoin", "bitcoincore-rpc", + "brk-aide", "brk_computer", "brk_error", "brk_fetcher", @@ -1144,6 +1166,7 @@ dependencies = [ "jiff", "log", "quick_cache", + "schemars 1.0.4", "serde", "sonic-rs 0.5.5", "tokio", @@ -1193,6 +1216,7 @@ dependencies = [ "num_enum", "rapidhash", "ryu", + "schemars 1.0.4", "serde", "serde_bytes", "strum", @@ -1939,9 +1963,9 @@ dependencies = [ [[package]] name = "flate2" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04bcaeafafdd3cd1cb5d986ff32096ad1136630207c49b9091e3ae541090d938" +checksum = "dc5a4e564e38c699f2880d3fda590bedc2e69f3f84cd48b457bd892ce61d0aa9" dependencies = [ "crc32fast", "libz-rs-sys", @@ -4136,6 +4160,7 @@ checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" dependencies = [ "chrono", "dyn-clone", + "indexmap 2.11.4", "ref-cast", "schemars_derive", "serde", @@ -4305,6 +4330,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_qs" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b417bedc008acbdf6d6b4bc482d29859924114bbe2650b7921fb68a261d0aa6" +dependencies = [ + "axum", + "futures", + "percent-encoding", + "serde", + "thiserror 2.0.17", +] + [[package]] name = "serde_spanned" version = "1.0.2" @@ -5042,9 +5080,9 @@ checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-width" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unicode-xid" diff --git a/Cargo.toml b/Cargo.toml index 884454c29..4511dce24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ panic = "abort" debug-assertions = false [workspace.dependencies] +aide = { version = "0.15.1", features = ["axum-json"], package = "brk-aide" } allocative = { version = "0.3.4", features = ["parking_lot"] } axum = "0.8.6" bitcoin = { version = "0.32.7", features = ["serde"] } diff --git a/crates/brk_computer/src/stateful/common.rs b/crates/brk_computer/src/stateful/common.rs index d34ae50c2..740499a15 100644 --- a/crates/brk_computer/src/stateful/common.rs +++ b/crates/brk_computer/src/stateful/common.rs @@ -1342,7 +1342,7 @@ impl Vecs { .height_to_supply .into_iter() .unwrap_get_inner(prev_height); - state.supply.utxos = *self + state.supply.utxo_count = *self .height_to_utxo_count .into_iter() .unwrap_get_inner(prev_height); @@ -1579,7 +1579,7 @@ impl Vecs { self.height_to_utxo_count.forced_push_at( height, - StoredU64::from(state.supply.utxos), + StoredU64::from(state.supply.utxo_count), exit, )?; diff --git a/crates/brk_computer/src/stateful/mod.rs b/crates/brk_computer/src/stateful/mod.rs index 9d654089b..a294e2cd2 100644 --- a/crates/brk_computer/src/stateful/mod.rs +++ b/crates/brk_computer/src/stateful/mod.rs @@ -1899,7 +1899,7 @@ impl AddressTypeToVec<(TypeIndex, Sats)> { let addressdata = addressdata_withsource.deref_mut(); - let prev_amount = addressdata.amount(); + let prev_amount = addressdata.balance(); let amount = prev_amount + value; @@ -2000,11 +2000,11 @@ impl HeightToAddressTypeToVec<(TypeIndex, Sats)> { let addressdata = addressdata_withsource.deref_mut(); - let prev_amount = addressdata.amount(); + let prev_amount = addressdata.balance(); let amount = prev_amount.checked_sub(value).unwrap(); - let will_be_empty = addressdata.utxos - 1 == 0; + let will_be_empty = addressdata.utxo_count - 1 == 0; if will_be_empty || vecs.amount_range.get_mut(amount).0.clone() diff --git a/crates/brk_computer/src/states/cohorts/address.rs b/crates/brk_computer/src/states/cohorts/address.rs index 4c7bbcfcf..8785c68d4 100644 --- a/crates/brk_computer/src/states/cohorts/address.rs +++ b/crates/brk_computer/src/states/cohorts/address.rs @@ -44,19 +44,22 @@ impl AddressCohortState { let prev_realized_price = compute_price.then(|| addressdata.realized_price()); let prev_supply_state = SupplyState { - utxos: addressdata.utxos as u64, - value: addressdata.amount(), + utxo_count: addressdata.utxo_count as u64, + value: addressdata.balance(), }; addressdata.send(value, prev_price)?; let supply_state = SupplyState { - utxos: addressdata.utxos as u64, - value: addressdata.amount(), + utxo_count: addressdata.utxo_count as u64, + value: addressdata.balance(), }; self.inner.send_( - &SupplyState { utxos: 1, value }, + &SupplyState { + utxo_count: 1, + value, + }, current_price, prev_price, blocks_old, @@ -79,19 +82,22 @@ impl AddressCohortState { let prev_realized_price = compute_price.then(|| address_data.realized_price()); let prev_supply_state = SupplyState { - utxos: address_data.utxos as u64, - value: address_data.amount(), + utxo_count: address_data.utxo_count as u64, + value: address_data.balance(), }; address_data.receive(value, price); let supply_state = SupplyState { - utxos: address_data.utxos as u64, - value: address_data.amount(), + utxo_count: address_data.utxo_count as u64, + value: address_data.balance(), }; self.inner.receive_( - &SupplyState { utxos: 1, value }, + &SupplyState { + utxo_count: 1, + value, + }, price, compute_price.then(|| (address_data.realized_price(), &supply_state)), prev_realized_price.map(|prev_price| (prev_price, &prev_supply_state)), diff --git a/crates/brk_computer/src/states/cohorts/common.rs b/crates/brk_computer/src/states/cohorts/common.rs index c1d2810dd..e53ebcaf0 100644 --- a/crates/brk_computer/src/states/cohorts/common.rs +++ b/crates/brk_computer/src/states/cohorts/common.rs @@ -204,7 +204,7 @@ impl CohortState { price_to_amount_increment: Option<(Dollars, &SupplyState)>, price_to_amount_decrement: Option<(Dollars, &SupplyState)>, ) { - if supply_state.utxos == 0 { + if supply_state.utxo_count == 0 { return; } diff --git a/crates/brk_computer/src/states/supply.rs b/crates/brk_computer/src/states/supply.rs index 0d38d058c..8e9a9dd71 100644 --- a/crates/brk_computer/src/states/supply.rs +++ b/crates/brk_computer/src/states/supply.rs @@ -6,7 +6,7 @@ use zerocopy_derive::{FromBytes, Immutable, IntoBytes, KnownLayout}; #[derive(Debug, Default, Clone, FromBytes, Immutable, IntoBytes, KnownLayout, Serialize)] pub struct SupplyState { - pub utxos: u64, + pub utxo_count: u64, pub value: Sats, } @@ -14,7 +14,7 @@ impl Add for SupplyState { type Output = Self; fn add(self, rhs: SupplyState) -> Self::Output { Self { - utxos: self.utxos + rhs.utxos, + utxo_count: self.utxo_count + rhs.utxo_count, value: self.value + rhs.value, } } @@ -28,14 +28,14 @@ impl AddAssign for SupplyState { impl AddAssign<&SupplyState> for SupplyState { fn add_assign(&mut self, rhs: &Self) { - self.utxos += rhs.utxos; + self.utxo_count += rhs.utxo_count; self.value += rhs.value; } } impl SubAssign<&SupplyState> for SupplyState { fn sub_assign(&mut self, rhs: &Self) { - self.utxos = self.utxos.checked_sub(rhs.utxos).unwrap(); + self.utxo_count = self.utxo_count.checked_sub(rhs.utxo_count).unwrap(); self.value = self.value.checked_sub(rhs.value).unwrap(); } } @@ -43,14 +43,14 @@ impl SubAssign<&SupplyState> for SupplyState { impl From<&LoadedAddressData> for SupplyState { fn from(value: &LoadedAddressData) -> Self { Self { - utxos: value.utxos as u64, - value: value.amount(), + utxo_count: value.utxo_count as u64, + value: value.balance(), } } } impl std::fmt::Display for SupplyState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "utxos: {}, value: {}", self.utxos, self.value) + write!(f, "utxos: {}, value: {}", self.utxo_count, self.value) } } diff --git a/crates/brk_computer/src/states/transacted.rs b/crates/brk_computer/src/states/transacted.rs index f48ed0921..53001c9ed 100644 --- a/crates/brk_computer/src/states/transacted.rs +++ b/crates/brk_computer/src/states/transacted.rs @@ -14,7 +14,10 @@ pub struct Transacted { impl Transacted { #[allow(clippy::inconsistent_digit_grouping)] pub fn iterate(&mut self, value: Sats, _type: OutputType) { - let supply = SupplyState { utxos: 1, value }; + let supply = SupplyState { + utxo_count: 1, + value, + }; *self.by_type.get_mut(_type) += &supply; diff --git a/crates/brk_mcp/Cargo.toml b/crates/brk_mcp/Cargo.toml index 09d788266..5f7a11e60 100644 --- a/crates/brk_mcp/Cargo.toml +++ b/crates/brk_mcp/Cargo.toml @@ -10,7 +10,7 @@ rust-version.workspace = true build = "build.rs" [dependencies] -axum = { workspace = true } +aide = { workspace = true } brk_interface = { workspace = true } brk_rmcp = { version = "0.8.0", features = [ "transport-worker", diff --git a/crates/brk_mcp/src/route.rs b/crates/brk_mcp/src/route.rs index 4b439d217..b3948c6dd 100644 --- a/crates/brk_mcp/src/route.rs +++ b/crates/brk_mcp/src/route.rs @@ -1,4 +1,4 @@ -use axum::Router; +use aide::axum::ApiRouter; use brk_interface::Interface; use brk_rmcp::transport::{ StreamableHttpServerConfig, @@ -13,7 +13,7 @@ pub trait MCPRoutes { fn add_mcp_routes(self, interface: &'static Interface<'static>, mcp: bool) -> Self; } -impl MCPRoutes for Router +impl MCPRoutes for ApiRouter where T: Clone + Send + Sync + 'static, { diff --git a/crates/brk_server/Cargo.toml b/crates/brk_server/Cargo.toml index b453dd6d8..6545cd2a3 100644 --- a/crates/brk_server/Cargo.toml +++ b/crates/brk_server/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true build = "build.rs" [dependencies] +aide = { workspace = true } axum = { workspace = true } bitcoin = { workspace = true } bitcoincore-rpc = { workspace = true } @@ -26,7 +27,9 @@ vecdb = { workspace = true } jiff = { workspace = true } log = { workspace = true } quick_cache = { workspace = true } +schemars = { workspace = true } serde = { workspace = true } +# serde_json = { workspace = true } sonic-rs = { workspace = true } tokio = { workspace = true } tower-http = { version = "0.6.6", features = ["compression-full", "trace"] } diff --git a/crates/brk_server/src/api/chain/mod.rs b/crates/brk_server/src/api/chain/mod.rs index 0bbe274e2..7e883a217 100644 --- a/crates/brk_server/src/api/chain/mod.rs +++ b/crates/brk_server/src/api/chain/mod.rs @@ -4,19 +4,27 @@ use std::{ str::FromStr, }; +use aide::{ + axum::{ApiRouter, IntoApiResponse, routing::get_with}, + transform::TransformOperation, +}; use axum::{ - Json, Router, + Json, extract::{Path, State}, + http::StatusCode, response::{IntoResponse, Response}, routing::get, }; -use bitcoin::{Address, Network, Transaction, consensus::Decodable}; +use bitcoin::{ + Address as BitcoinAddress, Network, Transaction as BitcoinTransaction, consensus::Decodable, +}; use brk_parser::XORIndex; use brk_structs::{ - AddressBytesHash, AnyAddressDataIndexEnum, Bitcoin, OutputType, TxIndex, Txid, TxidPrefix, + AddressBytesHash, AnyAddressDataIndexEnum, Bitcoin, Dollars, OutputType, Sats, TxIndex, Txid, + TxidPrefix, TypeIndex, }; -use serde::Serialize; -use sonic_rs::{Number, Value}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; use vecdb::{AnyIterableVec, VecIterator}; use super::AppState; @@ -25,127 +33,244 @@ pub trait ApiExplorerRoutes { fn add_api_explorer_routes(self) -> Self; } +#[derive(Debug, Serialize, JsonSchema)] +struct AddressDetails { + /// Bitcoin address string + address: String, + + r#type: OutputType, + + type_index: TypeIndex, + + /// Total satoshis ever sent from this address + total_sent: Sats, + + /// Total satoshis ever received by this address + total_received: Sats, + + /// Number of unspent transaction outputs (UTXOs) + utxo_count: u32, + + /// Current spendable balance in satoshis (total_received - total_sent) + balance: Sats, + + /// Current balance value in USD at current market price + balance_usd: Option, + + /// 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 + estimated_total_invested: Option, + + /// Estimated average BTC price at time of deposit for coins currently in this address (USD). Not suitable for tax calculations + estimated_avg_entry_price: Option, + // + // 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(Serialize)] struct TxResponse { txid: Txid, index: TxIndex, - tx: Transaction, + tx: BitcoinTransaction, } -impl ApiExplorerRoutes for Router { +#[derive(Deserialize, JsonSchema)] +struct AddressPath { + /// Bitcoin address string + address: String, +} + +#[derive(Deserialize, JsonSchema)] +struct TypeIndexPath { + /// Index of type + index: TypeIndex, +} + +async fn get_address_details_from_address( + Path(AddressPath { address }): Path, + state: State, +) -> impl IntoApiResponse { + let Ok(address) = BitcoinAddress::from_str(&address) else { + return StatusCode::BAD_REQUEST.into_response(); + }; + if !address.is_valid_for_network(Network::Bitcoin) { + return StatusCode::BAD_REQUEST.into_response(); + } + let address = address.assume_checked(); + let interface = state.interface; + let indexer = interface.indexer(); + let computer = interface.computer(); + let stores = &indexer.stores; + let hash = AddressBytesHash::from(&address); + + let r#type = OutputType::from(&address); + + let Ok(Some(type_index)) = stores + .addressbyteshash_to_typeindex + .get(&hash) + .map(|opt| opt.map(|cow| cow.into_owned())) + else { + return StatusCode::NOT_FOUND.into_response(); + }; + + 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 r#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()), + _ => unreachable!(), + }; + + 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(); + + Json(AddressDetails { + address: address.to_string(), + r#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()), + }) + .into_response() +} + +fn get_address_docs(op: TransformOperation) -> TransformOperation { + op.tag("Chain") + .summary("Get address information") + .description("Get Bitcoin address details") + .response_with::<200, Json, _>(|res| { + res.example(AddressDetails { + address: "bc1qwzrryqr3ja8w7hnja2spmkgfdcgvqwp5swz4af4ngsjecfz0w0pqud7k38" + .to_string(), + r#type: OutputType::P2WSH, + type_index: TypeIndex::new(26158889), + total_sent: Sats::new(5498948012620), + total_received: Sats::new(5557954331207), + utxo_count: 195, + balance: Sats::new(59006318587), + balance_usd: Some(Dollars::mint(73757839.22)), + estimated_total_invested: Some(Dollars::mint(71943052.66)), + estimated_avg_entry_price: Some(Dollars::mint(121900.0)), + }) + }) + .response_with::<400, (), _>(|res| res.description("The address provided was invalid")) + .response_with::<404, (), _>(|res| res.description("The address provided was not found")) +} + +impl ApiExplorerRoutes for ApiRouter { fn add_api_explorer_routes(self) -> Self { - self.route( + self.api_route( "/api/chain/address/{address}", - get( - async |Path(address): Path, state: State| -> Response { - let Ok(address) = Address::from_str(&address) else { - return "Invalid address".into_response(); - }; - if !address.is_valid_for_network(Network::Bitcoin) { - return "Invalid address".into_response(); - } - let address = address.assume_checked(); - let interface = state.interface; - let indexer = interface.indexer(); - let computer = interface.computer(); - let stores = &indexer.stores; - let hash = AddressBytesHash::from(&address); - - let Ok(Some(addri)) = stores - .addressbyteshash_to_typeindex - .get(&hash) - .map(|opt| opt.map(|cow| cow.into_owned())) - else { - return "Unknown address".into_response(); - }; - - let output_type = OutputType::from(&address); - 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 anyaddri = match output_type { - OutputType::P2PK33 => stateful - .p2pk33addressindex_to_anyaddressindex - .iter() - .unwrap_get_inner(addri.into()), - OutputType::P2PK65 => stateful - .p2pk65addressindex_to_anyaddressindex - .iter() - .unwrap_get_inner(addri.into()), - OutputType::P2PKH => stateful - .p2pkhaddressindex_to_anyaddressindex - .iter() - .unwrap_get_inner(addri.into()), - OutputType::P2SH => stateful - .p2shaddressindex_to_anyaddressindex - .iter() - .unwrap_get_inner(addri.into()), - OutputType::P2TR => stateful - .p2traddressindex_to_anyaddressindex - .iter() - .unwrap_get_inner(addri.into()), - OutputType::P2WPKH => stateful - .p2wpkhaddressindex_to_anyaddressindex - .iter() - .unwrap_get_inner(addri.into()), - OutputType::P2WSH => stateful - .p2wshaddressindex_to_anyaddressindex - .iter() - .unwrap_get_inner(addri.into()), - OutputType::P2A => stateful - .p2aaddressindex_to_anyaddressindex - .iter() - .unwrap_get_inner(addri.into()), - - _ => unreachable!(), - }; - - let addr_data = match anyaddri.to_enum() { - AnyAddressDataIndexEnum::Loaded(loadedi) => stateful - .loadedaddressindex_to_loadedaddressdata - .iter() - .unwrap_get_inner(loadedi), - AnyAddressDataIndexEnum::Empty(emptyi) => stateful - .emptyaddressindex_to_emptyaddressdata - .iter() - .unwrap_get_inner(emptyi) - .into(), - }; - - let amount = addr_data.amount(); - Json(sonic_rs::json!({ - "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 - })) - .into_response() - }, - ), + get_with(get_address_details_from_address, get_address_docs), + ) + .api_route( + "/api/chain/address/p2pk65/{index}", + get_with(get_address_details_from_address, get_address_docs), + ) + .api_route( + "/api/chain/address/p2pk33/{index}", + get_with(get_address_details_from_address, get_address_docs), + ) + .api_route( + "/api/chain/address/p2pkh/{index}", + get_with(get_address_details_from_address, get_address_docs), + ) + .api_route( + "/api/chain/address/p2sh/{index}", + get_with(get_address_details_from_address, get_address_docs), + ) + .api_route( + "/api/chain/address/p2wpkh/{index}", + get_with(get_address_details_from_address, get_address_docs), + ) + .api_route( + "/api/chain/address/p2wsh/{index}", + get_with(get_address_details_from_address, get_address_docs), + ) + .api_route( + "/api/chain/address/p2tr/{index}", + get_with(get_address_details_from_address, get_address_docs), + ) + .api_route( + "/api/chain/address/p2a/{index}", + get_with(get_address_details_from_address, get_address_docs), ) .route( "/api/chain/tx/{txid}", @@ -215,7 +340,7 @@ impl ApiExplorerRoutes for Router { xori.bytes(&mut buffer, parser.xor_bytes()); let mut reader = Cursor::new(buffer); - let Ok(tx) = Transaction::consensus_decode(&mut reader) else { + let Ok(tx) = BitcoinTransaction::consensus_decode(&mut reader) else { return "Error decoding transaction".into_response(); }; diff --git a/crates/brk_server/src/api/metrics/mod.rs b/crates/brk_server/src/api/metrics/mod.rs index d4dc195a7..97e2a4d3c 100644 --- a/crates/brk_server/src/api/metrics/mod.rs +++ b/crates/brk_server/src/api/metrics/mod.rs @@ -1,5 +1,6 @@ +use aide::axum::ApiRouter; use axum::{ - Json, Router, + Json, extract::{Path, Query, State}, http::{HeaderMap, Uri}, response::{IntoResponse, Response}, @@ -22,7 +23,7 @@ pub trait ApiMetricsRoutes { const TO_SEPARATOR: &str = "_to_"; -impl ApiMetricsRoutes for Router { +impl ApiMetricsRoutes for ApiRouter { fn add_api_metrics_routes(self) -> Self { self.route( "/api/metrics/count", diff --git a/crates/brk_server/src/api/mod.rs b/crates/brk_server/src/api/mod.rs index b943c720d..8f6ae6424 100644 --- a/crates/brk_server/src/api/mod.rs +++ b/crates/brk_server/src/api/mod.rs @@ -1,4 +1,5 @@ -use axum::{Router, response::Redirect, routing::get}; +use aide::axum::ApiRouter; +use axum::{response::Html, routing::get}; use crate::api::{chain::ApiExplorerRoutes, metrics::ApiMetricsRoutes}; @@ -11,17 +12,10 @@ pub trait ApiRoutes { fn add_api_routes(self) -> Self; } -impl ApiRoutes for Router { +impl ApiRoutes for ApiRouter { fn add_api_routes(self) -> Self { self.add_api_explorer_routes() .add_api_metrics_routes() - .route( - "/api", - get(|| async { - Redirect::temporary( - "https://github.com/bitcoinresearchkit/brk/tree/main/crates/brk_server#api", - ) - }), - ) + .route("/api", get(Html::from(include_str!("./scalar.html")))) } } diff --git a/crates/brk_server/src/api/scalar.html b/crates/brk_server/src/api/scalar.html new file mode 100644 index 000000000..c0537c123 --- /dev/null +++ b/crates/brk_server/src/api/scalar.html @@ -0,0 +1,26 @@ + + + + Scalar API Reference + + + + + +
+ + + + + + + diff --git a/crates/brk_server/src/files/mod.rs b/crates/brk_server/src/files/mod.rs index 71b31082b..45a087322 100644 --- a/crates/brk_server/src/files/mod.rs +++ b/crates/brk_server/src/files/mod.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; -use axum::{Router, routing::get}; +use aide::axum::ApiRouter; +use axum::routing::get; use super::AppState; @@ -12,7 +13,7 @@ pub trait FilesRoutes { fn add_files_routes(self, path: Option<&PathBuf>) -> Self; } -impl FilesRoutes for Router { +impl FilesRoutes for ApiRouter { fn add_files_routes(self, path: Option<&PathBuf>) -> Self { if path.is_some() { self.route("/{*path}", get(file_handler)) diff --git a/crates/brk_server/src/lib.rs b/crates/brk_server/src/lib.rs index 1f80fb254..93a278d74 100644 --- a/crates/brk_server/src/lib.rs +++ b/crates/brk_server/src/lib.rs @@ -5,9 +5,13 @@ use std::{path::PathBuf, sync::Arc, time::Duration}; +use aide::{ + axum::{ApiRouter, IntoApiResponse}, + openapi::{Info, OpenApi}, +}; use api::ApiRoutes; use axum::{ - Json, Router, + Extension, Json, body::{Body, Bytes}, http::{Request, Response, StatusCode, Uri}, middleware::Next, @@ -94,11 +98,11 @@ impl Server { .on_failure(()) .on_eos(()); - let router = Router::new() + let router = ApiRouter::new() .add_api_routes() .add_files_routes(state.path.as_ref()) .add_mcp_routes(state.interface, mcp) - .route("/version", get(Json(VERSION))) + .api_route("/version", aide::axum::routing::get(version)) .route( "/health", get(Json(sonic_rs::json!({ @@ -126,6 +130,7 @@ impl Server { get(Redirect::temporary("https://github.com/bitcoinresearchkit/brk?tab=readme-ov-file#hosting-as-a-service")), ) .route("/nostr", get(Redirect::temporary("https://primal.net/p/npub1jagmm3x39lmwfnrtvxcs9ac7g300y3dusv9lgzhk2e4x5frpxlrqa73v44"))) + .route("/api.json", get(serve_api)) .with_state(state) .layer(compression_layer) .layer(response_uri_layer) @@ -142,12 +147,36 @@ impl Server { port += 1; } + let mut api = OpenApi { + info: Info { + title: "Bitcoin Research Kit API".to_string(), + description: Some("A documentation for using BRK's API".to_string()), + ..Info::default() + }, + ..OpenApi::default() + }; + info!("Starting server on port {port}..."); let listener = listener.unwrap(); - serve(listener, router).await?; + serve( + listener, + router + .finish_api(&mut api) + .layer(Extension(Arc::new(api))) + .into_make_service(), + ) + .await?; Ok(()) } } + +async fn serve_api(Extension(api): Extension>) -> impl IntoApiResponse { + Json(api) +} + +async fn version() -> impl IntoApiResponse { + Json(VERSION) +} diff --git a/crates/brk_structs/Cargo.toml b/crates/brk_structs/Cargo.toml index c52514ce9..d373ca0f0 100644 --- a/crates/brk_structs/Cargo.toml +++ b/crates/brk_structs/Cargo.toml @@ -22,6 +22,7 @@ jiff = { workspace = true } num_enum = "0.7.4" rapidhash = "4.1.0" ryu = "1.0.20" +schemars = { workspace = true } serde = { workspace = true } serde_bytes = { workspace = true } strum = { version = "0.27", features = ["derive"] } diff --git a/crates/brk_structs/src/structs/dollars.rs b/crates/brk_structs/src/structs/dollars.rs index 95fefe8c9..1d0311065 100644 --- a/crates/brk_structs/src/structs/dollars.rs +++ b/crates/brk_structs/src/structs/dollars.rs @@ -7,6 +7,7 @@ use std::{ use allocative::Allocative; use derive_deref::Deref; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use vecdb::{CheckedSub, StoredCompressed}; use zerocopy_derive::{FromBytes, Immutable, IntoBytes, KnownLayout}; @@ -29,6 +30,7 @@ use super::{Bitcoin, Cents, Close, High, Sats, StoredF32, StoredF64}; Deserialize, StoredCompressed, Allocative, + JsonSchema, )] pub struct Dollars(f64); diff --git a/crates/brk_structs/src/structs/loadedaddressdata.rs b/crates/brk_structs/src/structs/loadedaddressdata.rs index 07d33db57..8f4c26160 100644 --- a/crates/brk_structs/src/structs/loadedaddressdata.rs +++ b/crates/brk_structs/src/structs/loadedaddressdata.rs @@ -11,23 +11,23 @@ pub struct LoadedAddressData { pub sent: Sats, pub received: Sats, pub realized_cap: Dollars, - pub utxos: u32, + pub utxo_count: u32, #[serde(skip)] padding: u32, } impl LoadedAddressData { - pub fn amount(&self) -> Sats { + pub fn balance(&self) -> Sats { (u64::from(self.received) - u64::from(self.sent)).into() } pub fn realized_price(&self) -> Dollars { - let p = (self.realized_cap / Bitcoin::from(self.amount())).round_to(4); + let p = (self.realized_cap / Bitcoin::from(self.balance())).round_to(4); if p.is_negative() { dbg!(( self.realized_cap, - self.amount(), - Bitcoin::from(self.amount()), + self.balance(), + Bitcoin::from(self.balance()), p )); panic!(""); @@ -37,17 +37,17 @@ impl LoadedAddressData { #[inline] pub fn has_0_sats(&self) -> bool { - self.amount() == Sats::ZERO + self.balance() == Sats::ZERO } #[inline] pub fn has_0_utxos(&self) -> bool { - self.utxos == 0 + self.utxo_count == 0 } pub fn receive(&mut self, amount: Sats, price: Option) { self.received += amount; - self.utxos += 1; + self.utxo_count += 1; if let Some(price) = price { let added = price * amount; self.realized_cap += added; @@ -59,11 +59,11 @@ impl LoadedAddressData { } pub fn send(&mut self, amount: Sats, previous_price: Option) -> Result<()> { - if self.amount() < amount { + if self.balance() < amount { return Err(Error::Str("Previous_amount smaller than sent amount")); } self.sent += amount; - self.utxos -= 1; + self.utxo_count -= 1; if let Some(previous_price) = previous_price { let subtracted = previous_price * amount; let realized_cap = self.realized_cap.checked_sub(subtracted).unwrap(); @@ -96,7 +96,7 @@ impl From<&EmptyAddressData> for LoadedAddressData { sent: value.transfered, received: value.transfered, realized_cap: Dollars::ZERO, - utxos: 0, + utxo_count: 0, padding: 0, } } @@ -107,7 +107,7 @@ impl std::fmt::Display for LoadedAddressData { write!( f, "sent: {}, received: {}, realized_cap: {}, utxos: {}", - self.sent, self.received, self.realized_cap, self.utxos + self.sent, self.received, self.realized_cap, self.utxo_count ) } } diff --git a/crates/brk_structs/src/structs/outputtype.rs b/crates/brk_structs/src/structs/outputtype.rs index 0912cdc49..244f60461 100644 --- a/crates/brk_structs/src/structs/outputtype.rs +++ b/crates/brk_structs/src/structs/outputtype.rs @@ -1,6 +1,7 @@ use bitcoin::{Address, AddressType, ScriptBuf, opcodes::all::OP_PUSHBYTES_2}; use brk_error::Error; -use serde::Serialize; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; use strum::Display; use zerocopy_derive::{FromBytes, Immutable, IntoBytes, KnownLayout}; @@ -18,10 +19,12 @@ use zerocopy_derive::{FromBytes, Immutable, IntoBytes, KnownLayout}; IntoBytes, KnownLayout, Serialize, + JsonSchema, )] #[serde(rename_all = "lowercase")] #[strum(serialize_all = "lowercase")] #[repr(u8)] +/// Type (P2PKH, P2WPKH, P2SH, P2TR, etc.) pub enum OutputType { P2PK65, P2PK33, @@ -33,249 +36,493 @@ pub enum OutputType { P2WSH, P2TR, P2A, + #[schemars(skip)] Dummy10, + #[schemars(skip)] Dummy11, + #[schemars(skip)] Dummy12, + #[schemars(skip)] Dummy13, + #[schemars(skip)] Dummy14, + #[schemars(skip)] Dummy15, + #[schemars(skip)] Dummy16, + #[schemars(skip)] Dummy17, + #[schemars(skip)] Dummy18, + #[schemars(skip)] Dummy19, + #[schemars(skip)] Dummy20, + #[schemars(skip)] Dummy21, + #[schemars(skip)] Dummy22, + #[schemars(skip)] Dummy23, + #[schemars(skip)] Dummy24, + #[schemars(skip)] Dummy25, + #[schemars(skip)] Dummy26, + #[schemars(skip)] Dummy27, + #[schemars(skip)] Dummy28, + #[schemars(skip)] Dummy29, + #[schemars(skip)] Dummy30, + #[schemars(skip)] Dummy31, + #[schemars(skip)] Dummy32, + #[schemars(skip)] Dummy33, + #[schemars(skip)] Dummy34, + #[schemars(skip)] Dummy35, + #[schemars(skip)] Dummy36, + #[schemars(skip)] Dummy37, + #[schemars(skip)] Dummy38, + #[schemars(skip)] Dummy39, + #[schemars(skip)] Dummy40, + #[schemars(skip)] Dummy41, + #[schemars(skip)] Dummy42, + #[schemars(skip)] Dummy43, + #[schemars(skip)] Dummy44, + #[schemars(skip)] Dummy45, + #[schemars(skip)] Dummy46, + #[schemars(skip)] Dummy47, + #[schemars(skip)] Dummy48, + #[schemars(skip)] Dummy49, + #[schemars(skip)] Dummy50, + #[schemars(skip)] Dummy51, + #[schemars(skip)] Dummy52, + #[schemars(skip)] Dummy53, + #[schemars(skip)] Dummy54, + #[schemars(skip)] Dummy55, + #[schemars(skip)] Dummy56, + #[schemars(skip)] Dummy57, + #[schemars(skip)] Dummy58, + #[schemars(skip)] Dummy59, + #[schemars(skip)] Dummy60, + #[schemars(skip)] Dummy61, + #[schemars(skip)] Dummy62, + #[schemars(skip)] Dummy63, + #[schemars(skip)] Dummy64, + #[schemars(skip)] Dummy65, + #[schemars(skip)] Dummy66, + #[schemars(skip)] Dummy67, + #[schemars(skip)] Dummy68, + #[schemars(skip)] Dummy69, + #[schemars(skip)] Dummy70, + #[schemars(skip)] Dummy71, + #[schemars(skip)] Dummy72, + #[schemars(skip)] Dummy73, + #[schemars(skip)] Dummy74, + #[schemars(skip)] Dummy75, + #[schemars(skip)] Dummy76, + #[schemars(skip)] Dummy77, + #[schemars(skip)] Dummy78, + #[schemars(skip)] Dummy79, + #[schemars(skip)] Dummy80, + #[schemars(skip)] Dummy81, + #[schemars(skip)] Dummy82, + #[schemars(skip)] Dummy83, + #[schemars(skip)] Dummy84, + #[schemars(skip)] Dummy85, + #[schemars(skip)] Dummy86, + #[schemars(skip)] Dummy87, + #[schemars(skip)] Dummy88, + #[schemars(skip)] Dummy89, + #[schemars(skip)] Dummy90, + #[schemars(skip)] Dummy91, + #[schemars(skip)] Dummy92, + #[schemars(skip)] Dummy93, + #[schemars(skip)] Dummy94, + #[schemars(skip)] Dummy95, + #[schemars(skip)] Dummy96, + #[schemars(skip)] Dummy97, + #[schemars(skip)] Dummy98, + #[schemars(skip)] Dummy99, + #[schemars(skip)] Dummy100, + #[schemars(skip)] Dummy101, + #[schemars(skip)] Dummy102, + #[schemars(skip)] Dummy103, + #[schemars(skip)] Dummy104, + #[schemars(skip)] Dummy105, + #[schemars(skip)] Dummy106, + #[schemars(skip)] Dummy107, + #[schemars(skip)] Dummy108, + #[schemars(skip)] Dummy109, + #[schemars(skip)] Dummy110, + #[schemars(skip)] Dummy111, + #[schemars(skip)] Dummy112, + #[schemars(skip)] Dummy113, + #[schemars(skip)] Dummy114, + #[schemars(skip)] Dummy115, + #[schemars(skip)] Dummy116, + #[schemars(skip)] Dummy117, + #[schemars(skip)] Dummy118, + #[schemars(skip)] Dummy119, + #[schemars(skip)] Dummy120, + #[schemars(skip)] Dummy121, + #[schemars(skip)] Dummy122, + #[schemars(skip)] Dummy123, + #[schemars(skip)] Dummy124, + #[schemars(skip)] Dummy125, + #[schemars(skip)] Dummy126, + #[schemars(skip)] Dummy127, + #[schemars(skip)] Dummy128, + #[schemars(skip)] Dummy129, + #[schemars(skip)] Dummy130, + #[schemars(skip)] Dummy131, + #[schemars(skip)] Dummy132, + #[schemars(skip)] Dummy133, + #[schemars(skip)] Dummy134, + #[schemars(skip)] Dummy135, + #[schemars(skip)] Dummy136, + #[schemars(skip)] Dummy137, + #[schemars(skip)] Dummy138, + #[schemars(skip)] Dummy139, + #[schemars(skip)] Dummy140, + #[schemars(skip)] Dummy141, + #[schemars(skip)] Dummy142, + #[schemars(skip)] Dummy143, + #[schemars(skip)] Dummy144, + #[schemars(skip)] Dummy145, + #[schemars(skip)] Dummy146, + #[schemars(skip)] Dummy147, + #[schemars(skip)] Dummy148, + #[schemars(skip)] Dummy149, + #[schemars(skip)] Dummy150, + #[schemars(skip)] Dummy151, + #[schemars(skip)] Dummy152, + #[schemars(skip)] Dummy153, + #[schemars(skip)] Dummy154, + #[schemars(skip)] Dummy155, + #[schemars(skip)] Dummy156, + #[schemars(skip)] Dummy157, + #[schemars(skip)] Dummy158, + #[schemars(skip)] Dummy159, + #[schemars(skip)] Dummy160, + #[schemars(skip)] Dummy161, + #[schemars(skip)] Dummy162, + #[schemars(skip)] Dummy163, + #[schemars(skip)] Dummy164, + #[schemars(skip)] Dummy165, + #[schemars(skip)] Dummy166, + #[schemars(skip)] Dummy167, + #[schemars(skip)] Dummy168, + #[schemars(skip)] Dummy169, + #[schemars(skip)] Dummy170, + #[schemars(skip)] Dummy171, + #[schemars(skip)] Dummy172, + #[schemars(skip)] Dummy173, + #[schemars(skip)] Dummy174, + #[schemars(skip)] Dummy175, + #[schemars(skip)] Dummy176, + #[schemars(skip)] Dummy177, + #[schemars(skip)] Dummy178, + #[schemars(skip)] Dummy179, + #[schemars(skip)] Dummy180, + #[schemars(skip)] Dummy181, + #[schemars(skip)] Dummy182, + #[schemars(skip)] Dummy183, + #[schemars(skip)] Dummy184, + #[schemars(skip)] Dummy185, + #[schemars(skip)] Dummy186, + #[schemars(skip)] Dummy187, + #[schemars(skip)] Dummy188, + #[schemars(skip)] Dummy189, + #[schemars(skip)] Dummy190, + #[schemars(skip)] Dummy191, + #[schemars(skip)] Dummy192, + #[schemars(skip)] Dummy193, + #[schemars(skip)] Dummy194, + #[schemars(skip)] Dummy195, + #[schemars(skip)] Dummy196, + #[schemars(skip)] Dummy197, + #[schemars(skip)] Dummy198, + #[schemars(skip)] Dummy199, + #[schemars(skip)] Dummy200, + #[schemars(skip)] Dummy201, + #[schemars(skip)] Dummy202, + #[schemars(skip)] Dummy203, + #[schemars(skip)] Dummy204, + #[schemars(skip)] Dummy205, + #[schemars(skip)] Dummy206, + #[schemars(skip)] Dummy207, + #[schemars(skip)] Dummy208, + #[schemars(skip)] Dummy209, + #[schemars(skip)] Dummy210, + #[schemars(skip)] Dummy211, + #[schemars(skip)] Dummy212, + #[schemars(skip)] Dummy213, + #[schemars(skip)] Dummy214, + #[schemars(skip)] Dummy215, + #[schemars(skip)] Dummy216, + #[schemars(skip)] Dummy217, + #[schemars(skip)] Dummy218, + #[schemars(skip)] Dummy219, + #[schemars(skip)] Dummy220, + #[schemars(skip)] Dummy221, + #[schemars(skip)] Dummy222, + #[schemars(skip)] Dummy223, + #[schemars(skip)] Dummy224, + #[schemars(skip)] Dummy225, + #[schemars(skip)] Dummy226, + #[schemars(skip)] Dummy227, + #[schemars(skip)] Dummy228, + #[schemars(skip)] Dummy229, + #[schemars(skip)] Dummy230, + #[schemars(skip)] Dummy231, + #[schemars(skip)] Dummy232, + #[schemars(skip)] Dummy233, + #[schemars(skip)] Dummy234, + #[schemars(skip)] Dummy235, + #[schemars(skip)] Dummy236, + #[schemars(skip)] Dummy237, + #[schemars(skip)] Dummy238, + #[schemars(skip)] Dummy239, + #[schemars(skip)] Dummy240, + #[schemars(skip)] Dummy241, + #[schemars(skip)] Dummy242, + #[schemars(skip)] Dummy243, + #[schemars(skip)] Dummy244, + #[schemars(skip)] Dummy245, + #[schemars(skip)] Dummy246, + #[schemars(skip)] Dummy247, + #[schemars(skip)] Dummy248, + #[schemars(skip)] Dummy249, + #[schemars(skip)] Dummy250, + #[schemars(skip)] Dummy251, + #[schemars(skip)] Dummy252, + #[schemars(skip)] Dummy253, Empty = 254, Unknown = 255, diff --git a/crates/brk_structs/src/structs/sats.rs b/crates/brk_structs/src/structs/sats.rs index 2ce055a71..765368d8f 100644 --- a/crates/brk_structs/src/structs/sats.rs +++ b/crates/brk_structs/src/structs/sats.rs @@ -6,6 +6,7 @@ use std::{ use allocative::Allocative; use bitcoin::Amount; use derive_deref::Deref; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use vecdb::{CheckedSub, StoredCompressed}; use zerocopy_derive::{FromBytes, Immutable, IntoBytes, KnownLayout}; @@ -32,6 +33,7 @@ use super::{Bitcoin, Cents, Dollars, Height}; Deserialize, StoredCompressed, Allocative, + JsonSchema, )] pub struct Sats(u64); diff --git a/crates/brk_structs/src/structs/typeindex.rs b/crates/brk_structs/src/structs/typeindex.rs index d4f5afd06..cc78e8afb 100644 --- a/crates/brk_structs/src/structs/typeindex.rs +++ b/crates/brk_structs/src/structs/typeindex.rs @@ -1,13 +1,15 @@ use std::ops::Add; use byteview::ByteView; -use serde::Serialize; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; use vecdb::{CheckedSub, StoredCompressed}; use zerocopy::IntoBytes; use zerocopy_derive::{FromBytes, Immutable, IntoBytes, KnownLayout}; use crate::copy_first_4bytes; +/// Index within its type (e.g., 0 for first P2WPKH address) #[derive( Debug, PartialEq, @@ -22,11 +24,17 @@ use crate::copy_first_4bytes; IntoBytes, KnownLayout, Serialize, + Deserialize, StoredCompressed, + JsonSchema, )] pub struct TypeIndex(u32); impl TypeIndex { + pub fn new(i: u32) -> Self { + Self(i) + } + pub fn increment(&mut self) { self.0 += 1; }