diff --git a/Cargo.lock b/Cargo.lock index 43fee5675..66451d322 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -504,6 +504,7 @@ dependencies = [ "brk_computer", "brk_error", "brk_fetcher", + "brk_grouper", "brk_indexer", "brk_interface", "brk_logger", @@ -512,6 +513,7 @@ dependencies = [ "brk_server", "brk_store", "brk_structs", + "brk_traversable", ] [[package]] @@ -710,6 +712,7 @@ dependencies = [ name = "brk_interface" version = "0.0.111" dependencies = [ + "bitcoin", "bitcoincore-rpc", "brk_computer", "brk_error", @@ -1178,7 +1181,6 @@ dependencies = [ "quick_cache", "schemars", "serde", - "serde_json", "sonic-rs 0.5.5", "tokio", "tower-http", diff --git a/crates/brk/Cargo.toml b/crates/brk/Cargo.toml index c88298e9b..63bfd9921 100644 --- a/crates/brk/Cargo.toml +++ b/crates/brk/Cargo.toml @@ -10,12 +10,15 @@ rust-version.workspace = true build = "build.rs" [features] +default = ["cli"] full = [ "binder", "bundler", + "cli", "computer", "error", "fetcher", + "grouper", "indexer", "interface", "logger", @@ -24,12 +27,15 @@ full = [ "server", "store", "structs", + "traversable", ] binder = ["brk_binder"] bundler = ["brk_bundler"] +cli = ["brk_cli"] computer = ["brk_computer"] error = ["brk_error"] fetcher = ["brk_fetcher"] +grouper = ["brk_grouper"] indexer = ["brk_indexer"] interface = ["brk_interface"] logger = ["brk_logger"] @@ -38,14 +44,16 @@ parser = ["brk_parser"] server = ["brk_server"] store = ["brk_store"] structs = ["brk_structs"] +traversable = ["brk_traversable"] [dependencies] brk_binder = { workspace = true, optional = true } brk_bundler = { workspace = true, optional = true } -brk_cli = { workspace = true } +brk_cli = { workspace = true, optional = true } brk_computer = { workspace = true, optional = true } brk_error = { workspace = true, optional = true } brk_fetcher = { workspace = true, optional = true } +brk_grouper = { workspace = true, optional = true } brk_indexer = { workspace = true, optional = true } brk_interface = { workspace = true, optional = true } brk_logger = { workspace = true, optional = true } @@ -54,6 +62,7 @@ brk_parser = { workspace = true, optional = true } brk_server = { workspace = true, optional = true } brk_store = { workspace = true, optional = true } brk_structs = { workspace = true, optional = true } +brk_traversable = { workspace = true, optional = true } [package.metadata.docs.rs] all-features = true diff --git a/crates/brk/src/lib.rs b/crates/brk/src/lib.rs index fceb49ee0..daf2aa566 100644 --- a/crates/brk/src/lib.rs +++ b/crates/brk/src/lib.rs @@ -8,13 +8,10 @@ pub use brk_binder as binder; #[doc(inline)] pub use brk_bundler as bundler; +#[cfg(feature = "cli")] #[doc(inline)] pub use brk_cli as cli; -#[cfg(feature = "structs")] -#[doc(inline)] -pub use brk_structs as structs; - #[cfg(feature = "computer")] #[doc(inline)] pub use brk_computer as computer; @@ -27,10 +24,18 @@ pub use brk_error as error; #[doc(inline)] pub use brk_fetcher as fetcher; +#[cfg(feature = "grouper")] +#[doc(inline)] +pub use brk_grouper as grouper; + #[cfg(feature = "indexer")] #[doc(inline)] pub use brk_indexer as indexer; +#[cfg(feature = "interface")] +#[doc(inline)] +pub use brk_interface as interface; + #[cfg(feature = "logger")] #[doc(inline)] pub use brk_logger as logger; @@ -43,10 +48,6 @@ pub use brk_mcp as mcp; #[doc(inline)] pub use brk_parser as parser; -#[cfg(feature = "interface")] -#[doc(inline)] -pub use brk_interface as interface; - #[cfg(feature = "server")] #[doc(inline)] pub use brk_server as server; @@ -54,3 +55,11 @@ pub use brk_server as server; #[cfg(feature = "store")] #[doc(inline)] pub use brk_store as store; + +#[cfg(feature = "structs")] +#[doc(inline)] +pub use brk_structs as structs; + +#[cfg(feature = "traversable")] +#[doc(inline)] +pub use brk_traversable as traversable; diff --git a/crates/brk_error/src/lib.rs b/crates/brk_error/src/lib.rs index 6e49ac211..1530c0bfd 100644 --- a/crates/brk_error/src/lib.rs +++ b/crates/brk_error/src/lib.rs @@ -27,6 +27,14 @@ pub enum Error { WrongAddressType, UnindexableDate, QuickCacheError, + + InvalidAddress, + InvalidNetwork, + InvalidTxid, + UnknownAddress, + UnknownTxid, + UnsupportedType(String), + Str(&'static str), String(String), } @@ -140,6 +148,16 @@ impl fmt::Display for Error { "Date cannot be indexed, must be 2009-01-03, 2009-01-09 or greater" ), + Error::InvalidTxid => write!(f, "The provided TXID appears to be invalid"), + Error::InvalidNetwork => write!(f, "Invalid network"), + Error::InvalidAddress => write!(f, "The provided address appears to be invalid"), + Error::UnknownAddress => write!( + f, + "Address not found in the blockchain (no transaction history)" + ), + Error::UnknownTxid => write!(f, "Failed to find the TXID in the blockchain"), + Error::UnsupportedType(t) => write!(f, "Unsupported type ({t})"), + Error::Str(s) => write!(f, "{s}"), Error::String(s) => write!(f, "{s}"), } diff --git a/crates/brk_interface/Cargo.toml b/crates/brk_interface/Cargo.toml index 28c2807ac..d27d93b64 100644 --- a/crates/brk_interface/Cargo.toml +++ b/crates/brk_interface/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true build = "build.rs" [dependencies] +bitcoin = { workspace = true } bitcoincore-rpc = { workspace = true } brk_computer = { workspace = true } brk_error = { workspace = true } diff --git a/crates/brk_interface/src/chain/addresses.rs b/crates/brk_interface/src/chain/addresses.rs new file mode 100644 index 000000000..6859d7fe6 --- /dev/null +++ b/crates/brk_interface/src/chain/addresses.rs @@ -0,0 +1,124 @@ +use std::str::FromStr; + +use bitcoin::{Address, Network, PublicKey, ScriptBuf}; +use brk_error::{Error, Result}; +use brk_structs::{ + AddressBytes, AddressBytesHash, AddressInfo, AddressPath, AnyAddressDataIndexEnum, Bitcoin, + OutputType, +}; +use vecdb::{AnyIterableVec, VecIterator}; + +use crate::Interface; + +pub fn get_address_info( + AddressPath { address }: AddressPath, + interface: &Interface, +) -> Result { + let indexer = interface.indexer(); + let computer = interface.computer(); + let stores = &indexer.stores; + + let script = if let Ok(address) = Address::from_str(&address) { + if !address.is_valid_for_network(Network::Bitcoin) { + return Err(Error::InvalidNetwork); + } + let address = address.assume_checked(); + address.script_pubkey() + } else if let Ok(pubkey) = PublicKey::from_str(&address) { + ScriptBuf::new_p2pk(&pubkey) + } else { + return Err(Error::InvalidAddress); + }; + + let type_ = OutputType::from(&script); + let Ok(bytes) = AddressBytes::try_from((&script, type_)) else { + return Err(Error::Str("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(Error::UnknownAddress); + }; + + 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()), + t => { + return Err(Error::UnsupportedType(t.to_string())); + } + }; + + 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(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()), + }) +} diff --git a/crates/brk_interface/src/chain/mod.rs b/crates/brk_interface/src/chain/mod.rs new file mode 100644 index 000000000..de62314e5 --- /dev/null +++ b/crates/brk_interface/src/chain/mod.rs @@ -0,0 +1,5 @@ +mod addresses; +mod transactions; + +pub use addresses::*; +pub use transactions::*; diff --git a/crates/brk_interface/src/chain/transactions.rs b/crates/brk_interface/src/chain/transactions.rs new file mode 100644 index 000000000..88332eaed --- /dev/null +++ b/crates/brk_interface/src/chain/transactions.rs @@ -0,0 +1,87 @@ +use std::{ + fs::File, + io::{Cursor, Read, Seek, SeekFrom}, + str::FromStr, +}; + +use bitcoin::{Transaction, consensus::Decodable}; +use brk_error::{Error, Result}; +use brk_parser::XORIndex; +use brk_structs::{TransactionInfo, Txid, TxidPath, TxidPrefix}; +use vecdb::VecIterator; + +use crate::Interface; + +pub fn get_transaction_info( + TxidPath { txid }: TxidPath, + interface: &Interface, +) -> Result { + let Ok(txid) = bitcoin::Txid::from_str(&txid) else { + return Err(Error::InvalidTxid); + }; + + let txid = Txid::from(txid); + let prefix = TxidPrefix::from(&txid); + 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(Error::UnknownTxid); + }; + + 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(Error::Str("Failed to get the correct blk file")); + }; + + let mut xori = XORIndex::default(); + xori.add_assign(position.offset() as usize); + + let Ok(mut file) = File::open(blk_path) else { + return Err(Error::Str("Failed to open blk file")); + }; + + if file + .seek(SeekFrom::Start(position.offset() as u64)) + .is_err() + { + return Err(Error::Str("Failed to seek position in file")); + } + + let mut buffer = vec![0u8; *len as usize]; + if file.read_exact(&mut buffer).is_err() { + return Err(Error::Str("Failed to read the transaction (read exact)")); + } + xori.bytes(&mut buffer, parser.xor_bytes()); + + let mut reader = Cursor::new(buffer); + let Ok(_) = Transaction::consensus_decode(&mut reader) else { + return Err(Error::Str("Failed decode the transaction")); + }; + + Ok(TransactionInfo { + txid, + index, + // tx + }) +} diff --git a/crates/brk_interface/src/lib.rs b/crates/brk_interface/src/lib.rs index dcbc6f5e4..10748ba1f 100644 --- a/crates/brk_interface/src/lib.rs +++ b/crates/brk_interface/src/lib.rs @@ -6,7 +6,10 @@ use brk_computer::Computer; use brk_error::{Error, Result}; use brk_indexer::Indexer; use brk_parser::Parser; -use brk_structs::{Height, Index, IndexInfo}; +use brk_structs::{ + AddressInfo, AddressPath, Format, Height, Index, IndexInfo, MetricCount, TransactionInfo, + TxidPath, +}; use brk_traversable::TreeNode; use nucleo_matcher::{ Config, Matcher, @@ -15,23 +18,22 @@ use nucleo_matcher::{ use quick_cache::sync::Cache; use vecdb::{AnyCollectableVec, AnyStoredVec}; -mod count; +mod chain; mod deser; -mod format; mod metrics; -mod output; mod pagination; mod params; mod vecs; -pub use count::*; -pub use format::Format; -pub use output::{Output, Value}; +pub use metrics::{Output, Value}; pub use pagination::{PaginatedIndexParam, PaginatedMetrics, PaginationParam}; pub use params::{Params, ParamsDeprec, ParamsOpt}; use vecs::Vecs; -use crate::vecs::{IndexToVec, MetricToVec}; +use crate::{ + chain::{get_address_info, get_transaction_info}, + vecs::{IndexToVec, MetricToVec}, +}; pub fn cached_errors() -> &'static Cache { static CACHE: OnceLock> = OnceLock::new(); @@ -65,6 +67,14 @@ impl<'a> Interface<'a> { Height::from(self.indexer.vecs.height_to_blockhash.stamp()) } + pub fn get_address_info(&self, address: AddressPath) -> Result { + get_address_info(address, self) + } + + pub fn get_transaction_info(&self, txid: TxidPath) -> Result { + get_transaction_info(txid, self) + } + pub fn search(&self, params: &Params) -> Result> { let metrics = ¶ms.metrics; let index = params.index; diff --git a/crates/brk_interface/src/metrics.rs b/crates/brk_interface/src/metrics/mod.rs similarity index 96% rename from crates/brk_interface/src/metrics.rs rename to crates/brk_interface/src/metrics/mod.rs index 69cd64fc7..6b366286b 100644 --- a/crates/brk_interface/src/metrics.rs +++ b/crates/brk_interface/src/metrics/mod.rs @@ -3,7 +3,10 @@ use std::fmt; use derive_deref::Deref; use schemars::JsonSchema; use serde::Deserialize; -use serde_json::Value; + +mod output; + +pub use output::*; #[derive(Debug, Deref, JsonSchema)] pub struct MaybeMetrics(Vec); @@ -33,7 +36,7 @@ impl<'de> Deserialize<'de> for MaybeMetrics { where D: serde::Deserializer<'de>, { - let value = Value::deserialize(deserializer)?; + let value = serde_json::Value::deserialize(deserializer)?; if let Some(str) = value.as_str() { if str.len() <= MAX_STRING_SIZE { diff --git a/crates/brk_interface/src/output.rs b/crates/brk_interface/src/metrics/output.rs similarity index 98% rename from crates/brk_interface/src/output.rs rename to crates/brk_interface/src/metrics/output.rs index a30a4a5d8..b2ea3bd41 100644 --- a/crates/brk_interface/src/output.rs +++ b/crates/brk_interface/src/metrics/output.rs @@ -1,4 +1,4 @@ -use crate::Format; +use brk_structs::Format; #[derive(Debug)] pub enum Output { diff --git a/crates/brk_interface/src/params.rs b/crates/brk_interface/src/params.rs index 43e55c517..107a78cef 100644 --- a/crates/brk_interface/src/params.rs +++ b/crates/brk_interface/src/params.rs @@ -1,11 +1,10 @@ use std::ops::Deref; -use brk_structs::Index; +use brk_structs::{Format, Index}; use schemars::JsonSchema; use serde::Deserialize; use crate::{ - Format, deser::{de_unquote_i64, de_unquote_usize}, metrics::MaybeMetrics, }; diff --git a/crates/brk_interface/src/vecs.rs b/crates/brk_interface/src/vecs.rs index c86c7fdc3..7cf94d6d9 100644 --- a/crates/brk_interface/src/vecs.rs +++ b/crates/brk_interface/src/vecs.rs @@ -86,8 +86,8 @@ impl<'a> Vecs<'a> { this.catalog.replace( TreeNode::Branch( [ - ("indexer".to_string(), indexer.vecs.to_tree_node()), - ("computer".to_string(), computer.to_tree_node()), + ("indexed".to_string(), indexer.vecs.to_tree_node()), + ("computed".to_string(), computer.to_tree_node()), ] .into_iter() .collect(), diff --git a/crates/brk_server/Cargo.toml b/crates/brk_server/Cargo.toml index 6c4a8df3c..db082b51f 100644 --- a/crates/brk_server/Cargo.toml +++ b/crates/brk_server/Cargo.toml @@ -30,7 +30,6 @@ 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/addresses.rs b/crates/brk_server/src/api/chain/addresses.rs index 6df0e194b..08a48a0d0 100644 --- a/crates/brk_server/src/api/chain/addresses.rs +++ b/crates/brk_server/src/api/chain/addresses.rs @@ -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, - - /// 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, - - /// 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, - // - // 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, - state: State, -) -> Result, (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::(|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 { 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, + State(app_state): State| + -> Result)> { + 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::(|res| res) + .with_not_modified() + .with_bad_request() + .with_not_found() + .with_server_error() + ), ) } } diff --git a/crates/brk_server/src/api/chain/transactions.rs b/crates/brk_server/src/api/chain/transactions.rs index 80cb355c2..3406ff6a4 100644 --- a/crates/brk_server/src/api/chain/transactions.rs +++ b/crates/brk_server/src/api/chain/transactions.rs @@ -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, - state: State, -) -> Result)> { - 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::(|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 { 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, + State(app_state): State| + -> Result)> { + 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::(|res| res) + .with_not_modified() + .with_bad_request() + .with_not_found() + .with_server_error(), + ), ) } } diff --git a/crates/brk_server/src/api/metrics/data.rs b/crates/brk_server/src/api/metrics/data.rs index d021fa3a5..7e7b695db 100644 --- a/crates/brk_server/src/api/metrics/data.rs +++ b/crates/brk_server/src/api/metrics/data.rs @@ -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; diff --git a/crates/brk_server/src/api/metrics/mod.rs b/crates/brk_server/src/api/metrics/mod.rs index 3b0cc41ec..0f8c2d66c 100644 --- a/crates/brk_server/src/api/metrics/mod.rs +++ b/crates/brk_server/src/api/metrics/mod.rs @@ -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 { diff --git a/crates/brk_server/src/api/mod.rs b/crates/brk_server/src/api/mod.rs index f4134fa72..f01d74989 100644 --- a/crates/brk_server/src/api/mod.rs +++ b/crates/brk_server/src/api/mod.rs @@ -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 { fn add_api_routes(self) -> Self { self.add_chain_routes() @@ -62,7 +53,7 @@ impl ApiRoutes for ApiRouter { async || -> Json { 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 { ), ) .route("/api", get(Html::from(include_str!("./scalar.html")))) + .route( + "/api/{*path}", + get(|| async { Redirect::permanent("/api") }), + ) } } diff --git a/crates/brk_server/src/api/openapi.rs b/crates/brk_server/src/api/openapi.rs index 820cb8d5c..cf2fcf2d6 100644 --- a/crates/brk_server/src/api/openapi.rs +++ b/crates/brk_server/src/api/openapi.rs @@ -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() } diff --git a/crates/brk_server/src/extended/mod.rs b/crates/brk_server/src/extended/mod.rs index a66e7f101..97492fb2b 100644 --- a/crates/brk_server/src/extended/mod.rs +++ b/crates/brk_server/src/extended/mod.rs @@ -1,7 +1,9 @@ mod header_map; mod response; +mod result; mod transform_operation; pub use header_map::*; pub use response::*; +pub use result::*; pub use transform_operation::*; diff --git a/crates/brk_server/src/extended/result.rs b/crates/brk_server/src/extended/result.rs new file mode 100644 index 000000000..681bdb864 --- /dev/null +++ b/crates/brk_server/src/extended/result.rs @@ -0,0 +1,24 @@ +use axum::{Json, http::StatusCode}; +use brk_error::{Error, Result}; + +pub trait ResultExtended { + fn to_server_result(self) -> Result)>; +} + +impl ResultExtended for Result { + fn to_server_result(self) -> Result)> { + self.map_err(|e| { + ( + match e { + Error::InvalidTxid + | Error::InvalidNetwork + | Error::InvalidAddress + | Error::UnsupportedType(_) => StatusCode::BAD_REQUEST, + Error::UnknownAddress | Error::UnknownTxid => StatusCode::NOT_FOUND, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }, + Json(e.to_string()), + ) + }) + } +} diff --git a/crates/brk_server/src/lib.rs b/crates/brk_server/src/lib.rs index 12432e220..b8b16cd12 100644 --- a/crates/brk_server/src/lib.rs +++ b/crates/brk_server/src/lib.rs @@ -99,25 +99,26 @@ impl Server { let router = ApiRouter::new() .add_api_routes() - .add_files_routes(state.path.as_ref()) .add_mcp_routes(state.interface, mcp) + .add_files_routes(state.path.as_ref()) .route( "/discord", get(Redirect::temporary("https://discord.gg/WACpShCB7M")), ) - .route("/crates", get(Redirect::temporary("https://crates.io/crates/brk"))) + .route("/crate", get(Redirect::temporary("https://crates.io/crates/brk"))) .route( "/status", get(Redirect::temporary("https://status.bitview.space")), ) .route("/github", get(Redirect::temporary("https://github.com/bitcoinresearchkit/brk"))) + .route("/changelog", get(Redirect::temporary("https://github.com/bitcoinresearchkit/brk/blob/main/docs/CHANGELOG.md"))) .route( - "/cli", - get(Redirect::temporary("https://crates.io/crates/brk_cli")), + "/install", + get(Redirect::temporary("https://github.com/bitcoinresearchkit/brk/blob/main/crates/brk_cli/README.md#brk_cli")), ) .route( - "/hosting", - get(Redirect::temporary("https://github.com/bitcoinresearchkit/brk?tab=readme-ov-file#hosting-as-a-service")), + "/service", + get(Redirect::temporary("https://github.com/bitcoinresearchkit/brk?tab=readme-ov-file#professional-hosting")), ) .route("/nostr", get(Redirect::temporary("https://primal.net/p/npub1jagmm3x39lmwfnrtvxcs9ac7g300y3dusv9lgzhk2e4x5frpxlrqa73v44"))) .with_state(state) diff --git a/crates/brk_structs/src/addressinfo.rs b/crates/brk_structs/src/addressinfo.rs new file mode 100644 index 000000000..f12e7c1c8 --- /dev/null +++ b/crates/brk_structs/src/addressinfo.rs @@ -0,0 +1,69 @@ +use schemars::JsonSchema; +use serde::Serialize; + +use crate::{Dollars, OutputType, Sats, TypeIndex}; + +#[derive(Debug, Serialize, JsonSchema)] +/// Address information +pub struct AddressInfo { + /// Bitcoin address string + #[schemars(example = &"04678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5f")] + pub address: String, + + #[schemars(example = OutputType::P2PK65)] + pub r#type: OutputType, + + #[schemars(example = TypeIndex::new(0))] + pub type_index: TypeIndex, + + /// Total satoshis ever sent from this address + #[schemars(example = Sats::new(0))] + pub total_sent: Sats, + + /// Total satoshis ever received by this address + #[schemars(example = Sats::new(5001008380))] + pub total_received: Sats, + + /// Number of unspent transaction outputs (UTXOs) + #[schemars(example = 10)] + pub utxo_count: u32, + + /// Current spendable balance in satoshis (total_received - total_sent) + #[schemars(example = Sats::new(5001008380))] + pub balance: Sats, + + /// Current balance value in USD at current market price + #[schemars(example = Some(Dollars::mint(6_157_891.64)))] + pub 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 + #[schemars(example = Some(Dollars::mint(6.2)))] + pub estimated_total_invested: Option, + + /// 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)))] + pub 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 +} diff --git a/crates/brk_structs/src/addresspath.rs b/crates/brk_structs/src/addresspath.rs new file mode 100644 index 000000000..92cde65f2 --- /dev/null +++ b/crates/brk_structs/src/addresspath.rs @@ -0,0 +1,9 @@ +use schemars::JsonSchema; +use serde::Deserialize; + +#[derive(Deserialize, JsonSchema)] +pub struct AddressPath { + /// Bitcoin address string + #[schemars(example = &"04678afdb0fe5548271967f1a67130b7105cd6a828e03909a67962e0ea1f61deb649f6bc3f4cef38c4f35504e51ec112de5c384df7ba0b8d578a4c702b6bf11d5f")] + pub address: String, +} diff --git a/crates/brk_structs/src/anyaddressindex.rs b/crates/brk_structs/src/anyaddressindex.rs index fdda99ba0..b58892b97 100644 --- a/crates/brk_structs/src/anyaddressindex.rs +++ b/crates/brk_structs/src/anyaddressindex.rs @@ -19,7 +19,7 @@ impl AnyAddressIndex { impl From for AnyAddressIndex { fn from(value: LoadedAddressIndex) -> Self { if u32::from(value) >= MIN_EMPTY_INDEX { - panic!("") + panic!("{value} is higher than MIN_EMPTY_INDEX ({MIN_EMPTY_INDEX})") } Self(*value) } diff --git a/crates/brk_interface/src/format.rs b/crates/brk_structs/src/format.rs similarity index 100% rename from crates/brk_interface/src/format.rs rename to crates/brk_structs/src/format.rs diff --git a/crates/brk_structs/src/health.rs b/crates/brk_structs/src/health.rs new file mode 100644 index 000000000..b5d7fc1b0 --- /dev/null +++ b/crates/brk_structs/src/health.rs @@ -0,0 +1,10 @@ +use schemars::JsonSchema; +use serde::Serialize; + +#[derive(Debug, Serialize, JsonSchema)] +/// Server health status +pub struct Health { + pub status: String, + pub service: String, + pub timestamp: String, +} diff --git a/crates/brk_structs/src/lib.rs b/crates/brk_structs/src/lib.rs index 9e6635600..e3d47273f 100644 --- a/crates/brk_structs/src/lib.rs +++ b/crates/brk_structs/src/lib.rs @@ -6,6 +6,8 @@ use brk_error::{Error, Result}; mod addressbytes; mod addressbyteshash; +mod addressinfo; +mod addresspath; mod anyaddressindex; mod bitcoin; mod blkmetadata; @@ -23,13 +25,17 @@ mod emptyaddressdata; mod emptyaddressindex; mod emptyoutputindex; mod feerate; +mod format; mod halvingepoch; +mod health; mod height; mod index; mod indexinfo; mod inputindex; mod loadedaddressdata; mod loadedaddressindex; +mod metriccount; +mod metricpath; mod monthindex; mod ohlc; mod opreturnindex; @@ -63,8 +69,10 @@ mod stored_u8; mod timestamp; mod treenode; mod txid; +mod txidpath; mod txidprefix; mod txindex; +mod txinfo; mod txversion; mod typeindex; mod typeindex_with_outputindex; @@ -78,6 +86,8 @@ mod yearindex; pub use addressbytes::*; pub use addressbyteshash::*; +pub use addressinfo::*; +pub use addresspath::*; pub use anyaddressindex::*; pub use bitcoin::*; pub use blkmetadata::*; @@ -95,13 +105,17 @@ pub use emptyaddressdata::*; pub use emptyaddressindex::*; pub use emptyoutputindex::*; pub use feerate::*; +pub use format::*; pub use halvingepoch::*; +pub use health::*; pub use height::*; pub use index::*; pub use indexinfo::*; pub use inputindex::*; pub use loadedaddressdata::*; pub use loadedaddressindex::*; +pub use metriccount::*; +pub use metricpath::*; pub use monthindex::*; pub use ohlc::*; pub use opreturnindex::*; @@ -135,8 +149,10 @@ pub use stored_u64::*; pub use timestamp::*; pub use treenode::*; pub use txid::*; +pub use txidpath::*; pub use txidprefix::*; pub use txindex::*; +pub use txinfo::*; pub use txversion::*; pub use typeindex::*; pub use typeindex_with_outputindex::*; diff --git a/crates/brk_interface/src/count.rs b/crates/brk_structs/src/metriccount.rs similarity index 100% rename from crates/brk_interface/src/count.rs rename to crates/brk_structs/src/metriccount.rs diff --git a/crates/brk_structs/src/metricpath.rs b/crates/brk_structs/src/metricpath.rs new file mode 100644 index 000000000..0b787489d --- /dev/null +++ b/crates/brk_structs/src/metricpath.rs @@ -0,0 +1,9 @@ +use schemars::JsonSchema; +use serde::Deserialize; + +#[derive(Deserialize, JsonSchema)] +pub struct MetricPath { + /// Metric name + #[schemars(example = &"price_close", example = &"market_cap", example = &"realized_price")] + pub metric: String, +} diff --git a/crates/brk_structs/src/txidpath.rs b/crates/brk_structs/src/txidpath.rs new file mode 100644 index 000000000..ecd78fb4c --- /dev/null +++ b/crates/brk_structs/src/txidpath.rs @@ -0,0 +1,9 @@ +use schemars::JsonSchema; +use serde::Deserialize; + +#[derive(Deserialize, JsonSchema)] +pub struct TxidPath { + /// Bitcoin transaction id + #[schemars(example = &"4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b")] + pub txid: String, +} diff --git a/crates/brk_structs/src/txinfo.rs b/crates/brk_structs/src/txinfo.rs new file mode 100644 index 000000000..4006ee693 --- /dev/null +++ b/crates/brk_structs/src/txinfo.rs @@ -0,0 +1,19 @@ +use schemars::JsonSchema; +use serde::Serialize; + +use crate::{TxIndex, Txid}; + +#[derive(Serialize, JsonSchema)] +/// Transaction Information +pub struct TransactionInfo { + #[schemars( + with = "String", + example = "4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b" + )] + pub txid: Txid, + #[schemars(example = TxIndex::new(0))] + pub index: TxIndex, + // #[serde(flatten)] + // #[schemars(with = "serde_json::Value")] + // pub tx: Transaction, +} diff --git a/docs/TODO.md b/docs/TODO.md index 6f7e47f5e..cecb311e4 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -83,11 +83,8 @@ - create map of all single words - do some kind of score with that ? - FEAT: discoverability - - catalog (tree/groups) - search - - BUG: failover to `/api` - - ???: no HTML / redirects ? - - FEAT: support keyed version when fetching dataset: {date: value} / {date: [value]} + - ???: remove redirects ? - FEAT: add support for https (rustls) - _STORE_ - FEAT: save height and version in one file diff --git a/websites/bitview/scripts/core/options/partial.js b/websites/bitview/scripts/core/options/partial.js index 11504fc99..2723f723a 100644 --- a/websites/bitview/scripts/core/options/partial.js +++ b/websites/bitview/scripts/core/options/partial.js @@ -4214,29 +4214,28 @@ export function createPartialOptions({ colors, brk }) { { name: "API", url: () => "/api", - title: "Link to API documentation", + title: "API documentation", }, { name: "MCP", url: () => - "https://github.com/bitcoinresearchkit/brk/tree/main/crates/brk_mcp#brk-mcp", - title: "Link to MCP documentation", + "https://github.com/bitcoinresearchkit/brk/blob/main/crates/brk_mcp/README.md#brk_mcp", + title: "Model Context Protocol documentation", }, { - name: "Crates", - url: () => "/crates", - title: "Link to BRK on crates.io", + name: "Crate", + url: () => "/crate", + title: "View on crates.io", }, { name: "Source", url: () => "/github", - title: "Link to BRK's repository", + title: "Source code and issues", }, { name: "Changelog", - url: () => - "https://github.com/bitcoinresearchkit/brk/blob/main/docs/CHANGELOG.md#changelog", - title: "BRK's changelog", + url: () => "/changelog", + title: "Release notes and changelog", }, ], }, @@ -4246,37 +4245,37 @@ export function createPartialOptions({ colors, brk }) { { name: "Status", url: () => "/status", - title: "Link to servers status", + title: "Service status and uptime", }, { - name: "Self", - url: () => "/cli", - title: "Link to self-hosting documentation", + name: "Self-host", + url: () => "/install", + title: "Install and run yourself", }, { - name: "As a service", - url: () => "/hosting", - title: "Link to hosting service", + name: "Service", + url: () => "/service", + title: "Hosted service offering", }, ], }, { - name: "Social", + name: "Community", tree: [ + { + name: "Discord", + url: () => "/discord", + title: "Join the Discord server", + }, { name: "GitHub", url: () => "/github", - title: "Link to Github", + title: "Source code and issues", }, { name: "Nostr", url: () => "/nostr", - title: "Link to BRK's nostr account", - }, - { - name: "Discord", - url: () => "/discord", - title: "Link to BRK's discord server", + title: "Follow on Nostr", }, ], }, diff --git a/websites/bitview/scripts/panes/simulation.js b/websites/bitview/scripts/panes/simulation.js index 3e27c9ee0..31b493299 100644 --- a/websites/bitview/scripts/panes/simulation.js +++ b/websites/bitview/scripts/panes/simulation.js @@ -386,7 +386,7 @@ export function init({ colors, createChartElement, signals, resources }) { }), }, fees: { - percentage: signals.createSignal(/** @type {number | null} */ (0.25), { + percentage: signals.createSignal(/** @type {number | null} */ (1), { save: { ...serdeOptNumber, keyPrefix, @@ -606,8 +606,6 @@ export function init({ colors, createChartElement, signals, resources }) { resultsElement.append(p2); const p3 = window.document.createElement("p"); resultsElement.append(p3); - const p4 = window.document.createElement("p"); - resultsElement.append(p4); const owner = signals.getOwner(); diff --git a/websites/bitview/scripts/panes/table.js b/websites/bitview/scripts/panes/table.js index e17eac5e6..b7602a8da 100644 --- a/websites/bitview/scripts/panes/table.js +++ b/websites/bitview/scripts/panes/table.js @@ -4,348 +4,6 @@ import { tableElement } from "../core/elements"; import { serdeMetrics, serdeString, serdeUnit } from "../core/serde"; import { resetParams } from "../core/url"; -/** - * @param {Object} args - * @param {Option} args.option - * @param {Signals} args.signals - * @param {BRK} args.brk - * @param {Resources} args.resources - */ -function createTable({ brk, signals, option, resources }) { - const indexToMetrics = createIndexToMetrics(metricToIndexes); - - const serializedIndexes = createSerializedIndexes(); - /** @type {SerializedIndex} */ - const defaultSerializedIndex = "height"; - const serializedIndex = /** @type {Signal} */ ( - signals.createSignal( - /** @type {SerializedIndex} */ (defaultSerializedIndex), - { - save: { - ...serdeString, - keyPrefix: "table", - key: "index", - }, - }, - ) - ); - const index = signals.createMemo(() => - serializedIndexToIndex(serializedIndex()), - ); - - const table = window.document.createElement("table"); - const obj = { - element: table, - /** @type {VoidFunction | undefined} */ - addRandomCol: undefined, - }; - - signals.createEffect(index, (index, prevIndex) => { - if (prevIndex !== undefined) { - resetParams(option); - } - - const possibleMetrics = indexToMetrics[index]; - - const columns = signals.createSignal(/** @type {Metric[]} */ ([]), { - equals: false, - save: { - ...serdeMetrics, - keyPrefix: `table-${serializedIndex()}`, - key: `columns`, - }, - }); - columns.set((l) => l.filter((id) => possibleMetrics.includes(id))); - - signals.createEffect(columns, (columns) => { - console.log(columns); - }); - - table.innerHTML = ""; - const thead = window.document.createElement("thead"); - table.append(thead); - const trHead = window.document.createElement("tr"); - thead.append(trHead); - const tbody = window.document.createElement("tbody"); - table.append(tbody); - - const rowElements = signals.createSignal( - /** @type {HTMLTableRowElement[]} */ ([]), - ); - - /** - * @param {Object} args - * @param {HTMLSelectElement} args.select - * @param {Unit} [args.unit] - * @param {(event: MouseEvent) => void} [args.onLeft] - * @param {(event: MouseEvent) => void} [args.onRight] - * @param {(event: MouseEvent) => void} [args.onRemove] - */ - function addThCol({ select, onLeft, onRight, onRemove, unit: _unit }) { - const th = window.document.createElement("th"); - th.scope = "col"; - trHead.append(th); - const div = window.document.createElement("div"); - div.append(select); - // const top = window.document.createElement("div"); - // div.append(top); - // top.append(select); - // top.append( - // createAnchorElement({ - // href: "", - // blank: true, - // }), - // ); - const bottom = window.document.createElement("div"); - const unit = window.document.createElement("span"); - if (_unit) { - unit.innerHTML = _unit; - } - const moveLeft = createButtonElement({ - inside: "←", - title: "Move column to the left", - onClick: onLeft || (() => {}), - }); - const moveRight = createButtonElement({ - inside: "→", - title: "Move column to the right", - onClick: onRight || (() => {}), - }); - const remove = createButtonElement({ - inside: "×", - title: "Remove column", - onClick: onRemove || (() => {}), - }); - bottom.append(unit); - bottom.append(moveLeft); - bottom.append(moveRight); - bottom.append(remove); - div.append(bottom); - th.append(div); - return { - element: th, - /** - * @param {Unit} _unit - */ - setUnit(_unit) { - unit.innerHTML = _unit; - }, - }; - } - - addThCol({ - ...createSelect({ - list: serializedIndexes, - signal: serializedIndex, - }), - unit: "index", - }); - - let from = 0; - let to = 0; - - resources - .getOrCreate(index, serializedIndex()) - .fetch() - .then((vec) => { - if (!vec) return; - from = /** @type {number} */ (vec[0]); - to = /** @type {number} */ (vec.at(-1)) + 1; - const trs = /** @type {HTMLTableRowElement[]} */ ([]); - for (let i = vec.length - 1; i >= 0; i--) { - const value = vec[i]; - const tr = window.document.createElement("tr"); - trs.push(tr); - tbody.append(tr); - const th = window.document.createElement("th"); - th.innerHTML = serializeValue({ - value, - unit: "index", - }); - th.scope = "row"; - tr.append(th); - } - rowElements.set(() => trs); - }); - - const owner = signals.getOwner(); - - /** - * @param {Metric} metric - * @param {number} [_colIndex] - */ - function addCol(metric, _colIndex = columns().length) { - signals.runWithOwner(owner, () => { - /** @type {VoidFunction | undefined} */ - let dispose; - signals.createRoot((_dispose) => { - dispose = _dispose; - - const metricOption = signals.createSignal({ - name: metric, - value: metric, - }); - const { select } = createSelect({ - list: possibleMetrics.map((metric) => ({ - name: metric, - value: metric, - })), - signal: metricOption, - }); - - signals.createEffect(metricOption, (metricOption) => { - select.style.width = `${21 + 7.25 * metricOption.name.length}px`; - }); - - if (_colIndex === columns().length) { - columns.set((l) => { - l.push(metric); - return l; - }); - } - - const colIndex = signals.createSignal(_colIndex); - - /** - * @param {boolean} right - * @returns {(event: MouseEvent) => void} - */ - function createMoveColumnFunction(right) { - return () => { - const oldColIndex = colIndex(); - const newColIndex = oldColIndex + (right ? 1 : -1); - - const currentTh = /** @type {HTMLTableCellElement} */ ( - trHead.childNodes[oldColIndex + 1] - ); - const oterTh = /** @type {HTMLTableCellElement} */ ( - trHead.childNodes[newColIndex + 1] - ); - - if (right) { - oterTh.after(currentTh); - } else { - oterTh.before(currentTh); - } - - columns.set((l) => { - [l[oldColIndex], l[newColIndex]] = [ - l[newColIndex], - l[oldColIndex], - ]; - return l; - }); - - const rows = rowElements(); - for (let i = 0; i < rows.length; i++) { - const element = rows[i].childNodes[oldColIndex + 1]; - const sibling = rows[i].childNodes[newColIndex + 1]; - const temp = element.textContent; - element.textContent = sibling.textContent; - sibling.textContent = temp; - } - }; - } - - const th = addThCol({ - select, - unit: serdeUnit.deserialize(metric), - onLeft: createMoveColumnFunction(false), - onRight: createMoveColumnFunction(true), - onRemove: () => { - const ci = colIndex(); - trHead.childNodes[ci + 1].remove(); - columns.set((l) => { - l.splice(ci, 1); - return l; - }); - const rows = rowElements(); - for (let i = 0; i < rows.length; i++) { - rows[i].childNodes[ci + 1].remove(); - } - dispose?.(); - }, - }); - - signals.createEffect(columns, () => { - colIndex.set(Array.from(trHead.children).indexOf(th.element) - 1); - }); - - console.log(colIndex()); - - signals.createEffect(rowElements, (rowElements) => { - if (!rowElements.length) return; - for (let i = 0; i < rowElements.length; i++) { - const td = window.document.createElement("td"); - rowElements[i].append(td); - } - - signals.createEffect( - () => metricOption().name, - (metric, prevMetric) => { - const unit = serdeUnit.deserialize(metric); - th.setUnit(unit); - - const vec = resources.getOrCreate(index, metric); - - vec.fetch({ from, to }); - - const fetchedKey = resources.genFetchedKey({ from, to }); - - columns.set((l) => { - const i = l.indexOf(prevMetric ?? metric); - if (i === -1) { - l.push(metric); - } else { - l[i] = metric; - } - return l; - }); - - signals.createEffect( - () => vec.fetched().get(fetchedKey)?.vec(), - (vec) => { - if (!vec?.length) return; - - const thIndex = colIndex() + 1; - - for (let i = 0; i < rowElements.length; i++) { - const iRev = vec.length - 1 - i; - const value = vec[iRev]; - // @ts-ignore - rowElements[i].childNodes[thIndex].innerHTML = - serializeValue({ - value, - unit, - }); - } - }, - ); - - return () => metric; - }, - ); - }); - }); - - signals.onCleanup(() => { - dispose?.(); - }); - }); - } - - columns().forEach((metric, colIndex) => addCol(metric, colIndex)); - - obj.addRandomCol = function () { - addCol(randomFromArray(possibleMetrics)); - }; - - return () => index; - }); - - return obj; -} - /** * @param {Object} args * @param {Signals} args.signals @@ -354,53 +12,397 @@ function createTable({ brk, signals, option, resources }) { * @param {BRK} args.brk */ export function init({ signals, option, resources, brk }) { - const parent = tableElement; - const { headerElement } = createHeader("Table"); - parent.append(headerElement); + tableElement.innerHTML = "wip, will hopefuly be back soon, sorry !"; - const div = window.document.createElement("div"); - parent.append(div); + // const parent = tableElement; + // const { headerElement } = createHeader("Table"); + // parent.append(headerElement); - const table = createTable({ - signals, - brk, - resources, - option, - }); - div.append(table.element); + // const div = window.document.createElement("div"); + // parent.append(div); - const span = window.document.createElement("span"); - span.innerHTML = "Add column"; - div.append( - createButtonElement({ - onClick: () => { - table.addRandomCol?.(); - }, - inside: span, - title: "Click or tap to add a column to the table", - }), - ); + // const table = createTable({ + // signals, + // brk, + // resources, + // option, + // }); + // div.append(table.element); + + // const span = window.document.createElement("span"); + // span.innerHTML = "Add column"; + // div.append( + // createButtonElement({ + // onClick: () => { + // table.addRandomCol?.(); + // }, + // inside: span, + // title: "Click or tap to add a column to the table", + // }), + // ); } +// /** +// * @param {Object} args +// * @param {Option} args.option +// * @param {Signals} args.signals +// * @param {BRK} args.brk +// * @param {Resources} args.resources +// */ +// function createTable({ brk, signals, option, resources }) { +// const indexToMetrics = createIndexToMetrics(metricToIndexes); + +// const serializedIndexes = createSerializedIndexes(); +// /** @type {SerializedIndex} */ +// const defaultSerializedIndex = "height"; +// const serializedIndex = /** @type {Signal} */ ( +// signals.createSignal( +// /** @type {SerializedIndex} */ (defaultSerializedIndex), +// { +// save: { +// ...serdeString, +// keyPrefix: "table", +// key: "index", +// }, +// }, +// ) +// ); +// const index = signals.createMemo(() => +// serializedIndexToIndex(serializedIndex()), +// ); + +// const table = window.document.createElement("table"); +// const obj = { +// element: table, +// /** @type {VoidFunction | undefined} */ +// addRandomCol: undefined, +// }; + +// signals.createEffect(index, (index, prevIndex) => { +// if (prevIndex !== undefined) { +// resetParams(option); +// } + +// const possibleMetrics = indexToMetrics[index]; + +// const columns = signals.createSignal(/** @type {Metric[]} */ ([]), { +// equals: false, +// save: { +// ...serdeMetrics, +// keyPrefix: `table-${serializedIndex()}`, +// key: `columns`, +// }, +// }); +// columns.set((l) => l.filter((id) => possibleMetrics.includes(id))); + +// signals.createEffect(columns, (columns) => { +// console.log(columns); +// }); + +// table.innerHTML = ""; +// const thead = window.document.createElement("thead"); +// table.append(thead); +// const trHead = window.document.createElement("tr"); +// thead.append(trHead); +// const tbody = window.document.createElement("tbody"); +// table.append(tbody); + +// const rowElements = signals.createSignal( +// /** @type {HTMLTableRowElement[]} */ ([]), +// ); + +// /** +// * @param {Object} args +// * @param {HTMLSelectElement} args.select +// * @param {Unit} [args.unit] +// * @param {(event: MouseEvent) => void} [args.onLeft] +// * @param {(event: MouseEvent) => void} [args.onRight] +// * @param {(event: MouseEvent) => void} [args.onRemove] +// */ +// function addThCol({ select, onLeft, onRight, onRemove, unit: _unit }) { +// const th = window.document.createElement("th"); +// th.scope = "col"; +// trHead.append(th); +// const div = window.document.createElement("div"); +// div.append(select); +// // const top = window.document.createElement("div"); +// // div.append(top); +// // top.append(select); +// // top.append( +// // createAnchorElement({ +// // href: "", +// // blank: true, +// // }), +// // ); +// const bottom = window.document.createElement("div"); +// const unit = window.document.createElement("span"); +// if (_unit) { +// unit.innerHTML = _unit; +// } +// const moveLeft = createButtonElement({ +// inside: "←", +// title: "Move column to the left", +// onClick: onLeft || (() => {}), +// }); +// const moveRight = createButtonElement({ +// inside: "→", +// title: "Move column to the right", +// onClick: onRight || (() => {}), +// }); +// const remove = createButtonElement({ +// inside: "×", +// title: "Remove column", +// onClick: onRemove || (() => {}), +// }); +// bottom.append(unit); +// bottom.append(moveLeft); +// bottom.append(moveRight); +// bottom.append(remove); +// div.append(bottom); +// th.append(div); +// return { +// element: th, +// /** +// * @param {Unit} _unit +// */ +// setUnit(_unit) { +// unit.innerHTML = _unit; +// }, +// }; +// } + +// addThCol({ +// ...createSelect({ +// list: serializedIndexes, +// signal: serializedIndex, +// }), +// unit: "index", +// }); + +// let from = 0; +// let to = 0; + +// resources +// .getOrCreate(index, serializedIndex()) +// .fetch() +// .then((vec) => { +// if (!vec) return; +// from = /** @type {number} */ (vec[0]); +// to = /** @type {number} */ (vec.at(-1)) + 1; +// const trs = /** @type {HTMLTableRowElement[]} */ ([]); +// for (let i = vec.length - 1; i >= 0; i--) { +// const value = vec[i]; +// const tr = window.document.createElement("tr"); +// trs.push(tr); +// tbody.append(tr); +// const th = window.document.createElement("th"); +// th.innerHTML = serializeValue({ +// value, +// unit: "index", +// }); +// th.scope = "row"; +// tr.append(th); +// } +// rowElements.set(() => trs); +// }); + +// const owner = signals.getOwner(); + +// /** +// * @param {Metric} metric +// * @param {number} [_colIndex] +// */ +// function addCol(metric, _colIndex = columns().length) { +// signals.runWithOwner(owner, () => { +// /** @type {VoidFunction | undefined} */ +// let dispose; +// signals.createRoot((_dispose) => { +// dispose = _dispose; + +// const metricOption = signals.createSignal({ +// name: metric, +// value: metric, +// }); +// const { select } = createSelect({ +// list: possibleMetrics.map((metric) => ({ +// name: metric, +// value: metric, +// })), +// signal: metricOption, +// }); + +// signals.createEffect(metricOption, (metricOption) => { +// select.style.width = `${21 + 7.25 * metricOption.name.length}px`; +// }); + +// if (_colIndex === columns().length) { +// columns.set((l) => { +// l.push(metric); +// return l; +// }); +// } + +// const colIndex = signals.createSignal(_colIndex); + +// /** +// * @param {boolean} right +// * @returns {(event: MouseEvent) => void} +// */ +// function createMoveColumnFunction(right) { +// return () => { +// const oldColIndex = colIndex(); +// const newColIndex = oldColIndex + (right ? 1 : -1); + +// const currentTh = /** @type {HTMLTableCellElement} */ ( +// trHead.childNodes[oldColIndex + 1] +// ); +// const oterTh = /** @type {HTMLTableCellElement} */ ( +// trHead.childNodes[newColIndex + 1] +// ); + +// if (right) { +// oterTh.after(currentTh); +// } else { +// oterTh.before(currentTh); +// } + +// columns.set((l) => { +// [l[oldColIndex], l[newColIndex]] = [ +// l[newColIndex], +// l[oldColIndex], +// ]; +// return l; +// }); + +// const rows = rowElements(); +// for (let i = 0; i < rows.length; i++) { +// const element = rows[i].childNodes[oldColIndex + 1]; +// const sibling = rows[i].childNodes[newColIndex + 1]; +// const temp = element.textContent; +// element.textContent = sibling.textContent; +// sibling.textContent = temp; +// } +// }; +// } + +// const th = addThCol({ +// select, +// unit: serdeUnit.deserialize(metric), +// onLeft: createMoveColumnFunction(false), +// onRight: createMoveColumnFunction(true), +// onRemove: () => { +// const ci = colIndex(); +// trHead.childNodes[ci + 1].remove(); +// columns.set((l) => { +// l.splice(ci, 1); +// return l; +// }); +// const rows = rowElements(); +// for (let i = 0; i < rows.length; i++) { +// rows[i].childNodes[ci + 1].remove(); +// } +// dispose?.(); +// }, +// }); + +// signals.createEffect(columns, () => { +// colIndex.set(Array.from(trHead.children).indexOf(th.element) - 1); +// }); + +// console.log(colIndex()); + +// signals.createEffect(rowElements, (rowElements) => { +// if (!rowElements.length) return; +// for (let i = 0; i < rowElements.length; i++) { +// const td = window.document.createElement("td"); +// rowElements[i].append(td); +// } + +// signals.createEffect( +// () => metricOption().name, +// (metric, prevMetric) => { +// const unit = serdeUnit.deserialize(metric); +// th.setUnit(unit); + +// const vec = resources.getOrCreate(index, metric); + +// vec.fetch({ from, to }); + +// const fetchedKey = resources.genFetchedKey({ from, to }); + +// columns.set((l) => { +// const i = l.indexOf(prevMetric ?? metric); +// if (i === -1) { +// l.push(metric); +// } else { +// l[i] = metric; +// } +// return l; +// }); + +// signals.createEffect( +// () => vec.fetched().get(fetchedKey)?.vec(), +// (vec) => { +// if (!vec?.length) return; + +// const thIndex = colIndex() + 1; + +// for (let i = 0; i < rowElements.length; i++) { +// const iRev = vec.length - 1 - i; +// const value = vec[iRev]; +// // @ts-ignore +// rowElements[i].childNodes[thIndex].innerHTML = +// serializeValue({ +// value, +// unit, +// }); +// } +// }, +// ); + +// return () => metric; +// }, +// ); +// }); +// }); + +// signals.onCleanup(() => { +// dispose?.(); +// }); +// }); +// } + +// columns().forEach((metric, colIndex) => addCol(metric, colIndex)); + +// obj.addRandomCol = function () { +// addCol(randomFromArray(possibleMetrics)); +// }; + +// return () => index; +// }); + +// return obj; +// } + /** * @param {MetricToIndexes} metricToIndexes */ function createIndexToMetrics(metricToIndexes) { - const indexToMetrics = Object.entries(metricToIndexes).reduce( - (arr, [_id, indexes]) => { - const id = /** @type {Metric} */ (_id); - indexes.forEach((i) => { - arr[i] ??= []; - arr[i].push(id); - }); - return arr; - }, - /** @type {Metric[][]} */ (Array.from({ length: 24 })), - ); - indexToMetrics.forEach((arr) => { - arr.sort(); - }); - return indexToMetrics; + // const indexToMetrics = Object.entries(metricToIndexes).reduce( + // (arr, [_id, indexes]) => { + // const id = /** @type {Metric} */ (_id); + // indexes.forEach((i) => { + // arr[i] ??= []; + // arr[i].push(id); + // }); + // return arr; + // }, + // /** @type {Metric[][]} */ (Array.from({ length: 24 })), + // ); + // indexToMetrics.forEach((arr) => { + // arr.sort(); + // }); + // return indexToMetrics; } /** diff --git a/websites/bitview/service-worker.js b/websites/bitview/service-worker.js index b14e64b9f..f423cd195 100644 --- a/websites/bitview/service-worker.js +++ b/websites/bitview/service-worker.js @@ -53,16 +53,16 @@ sw.addEventListener("fetch", (event) => { if ( req.method !== "GET" || url.pathname.startsWith("/api") || - url.pathname === "/mcp" || - url.pathname === "/crates" || - url.pathname === "/github" || - url.pathname === "/status" || - url.pathname === "/cli" || - url.pathname === "/hosting" || - url.pathname === "/nostr" || + url.pathname === "/changelog" || + url.pathname === "/crate" || url.pathname === "/discord" || - url.pathname === "/nostr" || + url.pathname === "/github" || url.pathname === "/health" || + url.pathname === "/install" || + url.pathname === "/mcp" || + url.pathname === "/nostr" || + url.pathname === "/service" || + url.pathname === "/status" || url.pathname === "/version" ) { return; // let the browser handle it