diff --git a/crates/brk_client/src/lib.rs b/crates/brk_client/src/lib.rs index 8defadabd..d97fc8218 100644 --- a/crates/brk_client/src/lib.rs +++ b/crates/brk_client/src/lib.rs @@ -9538,7 +9538,7 @@ pub struct BrkClient { impl BrkClient { /// Client version. - pub const VERSION: &'static str = "v0.3.3"; + pub const VERSION: &'static str = "v0.3.4"; /// Create a new client with the given base URL. pub fn new(base_url: impl Into) -> Self { @@ -9866,6 +9866,15 @@ impl BrkClient { self.base.get_json(&path) } + /// Address hash-prefix matches + /// + /// Find addresses by address type and address-payload hash prefix. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata for the returned addresses through `/api/address/{address}`. + /// + /// Endpoint: `GET /api/address/hash-prefix/{addr_type}/{prefix}` + pub fn get_address_hash_prefix_matches(&self, addr_type: OutputType, prefix: &str) -> Result { + self.base.get_json(&format!("/api/address/hash-prefix/{addr_type}/{prefix}")) + } + /// Address information /// /// Retrieve address information including balance and transaction counts. Supports all standard Bitcoin address types (P2PKH, P2SH, P2WPKH, P2WSH, P2TR). diff --git a/crates/brk_query/src/impl/addr.rs b/crates/brk_query/src/impl/addr.rs deleted file mode 100644 index 08c7e1b67..000000000 --- a/crates/brk_query/src/impl/addr.rs +++ /dev/null @@ -1,279 +0,0 @@ -use std::str::FromStr; - -use bitcoin::{Network, PublicKey, ScriptBuf}; -use brk_error::{Error, OptionData, Result}; -use brk_types::{ - Addr, AddrBytes, AddrChainStats, AddrHash, AddrIndexOutPoint, AddrIndexTxIndex, AddrStats, - AnyAddrDataIndexEnum, Dollars, Height, OutputType, Transaction, TxIndex, TxStatus, Txid, - TypeIndex, Unit, Utxo, Vout, -}; -use vecdb::VecIndex; - -use crate::Query; - -impl Query { - pub fn addr(&self, addr: Addr) -> Result { - let computer = self.computer(); - - let script = if let Ok(addr) = bitcoin::Address::from_str(&addr) { - if !addr.is_valid_for_network(Network::Bitcoin) { - return Err(Error::InvalidNetwork); - } - let addr = addr.assume_checked(); - addr.script_pubkey() - } else if let Ok(pubkey) = PublicKey::from_str(&addr) { - ScriptBuf::new_p2pk(&pubkey) - } else { - return Err(Error::InvalidAddr); - }; - - let output_type = OutputType::from(&script); - let Ok(bytes) = AddrBytes::try_from((&script, output_type)) else { - return Err(Error::InvalidAddr); - }; - let hash = AddrHash::from(&bytes); - let type_index = self.type_index_for(output_type, &hash)?; - - if type_index >= self.safe_lengths().to_type_index(output_type) { - return Err(Error::UnknownAddr); - } - - let any_addr_index = computer - .distribution - .any_addr_indexes - .get_once(output_type, type_index)?; - - let (addr_data, realized_price) = match any_addr_index.to_enum() { - AnyAddrDataIndexEnum::Funded(index) => { - let data = computer - .distribution - .addrs_data - .funded - .reader() - .get(usize::from(index)); - let price = data.realized_price().to_dollars(); - (data, price) - } - AnyAddrDataIndexEnum::Empty(index) => { - let data = computer - .distribution - .addrs_data - .empty - .reader() - .get(usize::from(index)) - .into(); - (data, Dollars::default()) - } - }; - - Ok(AddrStats { - addr, - addr_type: output_type, - chain_stats: AddrChainStats { - type_index, - funded_txo_count: addr_data.funded_txo_count, - funded_txo_sum: addr_data.received, - spent_txo_count: addr_data.spent_txo_count, - spent_txo_sum: addr_data.sent, - tx_count: addr_data.tx_count, - realized_price, - }, - mempool_stats: self - .mempool() - .and_then(|m| m.addr_stats(&bytes)) - .unwrap_or_default(), - }) - } - - pub fn addr_txs_chain( - &self, - addr: &Addr, - after_txid: Option, - limit: usize, - ) -> Result> { - let txindices = self.addr_txindices(addr, after_txid, limit)?; - self.transactions_by_indices(&txindices) - } - - pub fn addr_txids( - &self, - addr: Addr, - after_txid: Option, - limit: usize, - ) -> Result> { - let txindices = self.addr_txindices(&addr, after_txid, limit)?; - let txid_reader = self.indexer().vecs.transactions.txid.reader(); - Ok(txindices - .into_iter() - .map(|tx_index| txid_reader.get(tx_index.to_usize())) - .collect()) - } - - fn addr_txindices( - &self, - addr: &Addr, - after_txid: Option, - limit: usize, - ) -> Result> { - let stores = &self.indexer().stores; - - let (output_type, type_index) = self.resolve_addr(addr)?; - - let store = stores - .addr_type_to_addr_index_and_tx_index - .get(output_type) - .data()?; - - let tx_index_len = self.safe_lengths().tx_index; - - if let Some(after_txid) = after_txid { - let after_tx_index = self.resolve_tx_index(&after_txid)?; - let min = AddrIndexTxIndex::min_for_addr(type_index); - let cursor = AddrIndexTxIndex::from((type_index, after_tx_index)); - Ok(store - .range(min..cursor) - .rev() - .map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index()) - .filter(|tx_index| *tx_index < tx_index_len) - .take(limit) - .collect()) - } else { - Ok(store - .prefix(type_index) - .rev() - .map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index()) - .filter(|tx_index| *tx_index < tx_index_len) - .take(limit) - .collect()) - } - } - - pub fn addr_utxos(&self, addr: Addr, max_utxos: usize) -> Result> { - let indexer = self.indexer(); - let stores = &indexer.stores; - let vecs = &indexer.vecs; - - let (output_type, type_index) = self.resolve_addr(&addr)?; - - let store = stores - .addr_type_to_addr_index_and_unspent_outpoint - .get(output_type) - .data()?; - - let tx_index_len = self.safe_lengths().tx_index; - let outpoints: Vec<(TxIndex, Vout)> = store - .prefix(type_index) - .map(|(key, _): (AddrIndexOutPoint, Unit)| (key.tx_index(), key.vout())) - .filter(|(tx_index, _)| *tx_index < tx_index_len) - .take(max_utxos + 1) - .collect(); - if outpoints.len() > max_utxos { - return Err(Error::TooManyUtxos); - } - - let txid_reader = vecs.transactions.txid.reader(); - let first_txout_index_reader = vecs.transactions.first_txout_index.reader(); - let value_reader = vecs.outputs.value.reader(); - - let mut cached_status: Option<(Height, TxStatus)> = None; - let mut utxos = Vec::with_capacity(outpoints.len()); - - for (tx_index, vout) in outpoints { - let txid = txid_reader.get(tx_index.to_usize()); - let first_txout_index = first_txout_index_reader.get(tx_index.to_usize()); - let value = value_reader.get(usize::from(first_txout_index + vout)); - - let height = self.confirmed_status_height(tx_index)?; - let status = if let Some((h, ref s)) = cached_status - && h == height - { - s.clone() - } else { - let s = self.confirmed_status_at(height)?; - cached_status = Some((height, s.clone())); - s - }; - - utxos.push(Utxo { - txid, - vout, - status, - value, - }); - } - - Ok(utxos) - } - - pub fn addr_mempool_hash(&self, addr: &Addr) -> Option { - let mempool = self.mempool()?; - let bytes = AddrBytes::from_str(addr).ok()?; - mempool.addr_state_hash(&bytes) - } - - pub fn addr_mempool_txs(&self, addr: &Addr, limit: usize) -> Result> { - let bytes = AddrBytes::from_str(addr)?; - let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?; - Ok(mempool.addr_txs(&bytes, limit)) - } - - /// Height of the last on-chain activity for an address (last tx_index to height). - /// With `before_txid`, returns the newest activity strictly older than that - /// cursor. Used by paginated chain etags so a new tx above the cursor - /// doesn't invalidate deeper pages. - pub fn addr_last_activity_height( - &self, - addr: &Addr, - before_txid: Option<&Txid>, - ) -> Result { - let (output_type, type_index) = self.resolve_addr(addr)?; - let store = self - .indexer() - .stores - .addr_type_to_addr_index_and_tx_index - .get(output_type) - .data()?; - let tx_index_len = self.safe_lengths().tx_index; - let last_tx_index = match before_txid { - Some(txid) => { - let before_tx_index = self.resolve_tx_index(txid)?; - let min = AddrIndexTxIndex::min_for_addr(type_index); - let cursor = AddrIndexTxIndex::from((type_index, before_tx_index)); - store - .range(min..cursor) - .rev() - .map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index()) - .find(|tx_index| *tx_index < tx_index_len) - .ok_or(Error::UnknownAddr)? - } - None => store - .prefix(type_index) - .rev() - .map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index()) - .find(|tx_index| *tx_index < tx_index_len) - .ok_or(Error::UnknownAddr)?, - }; - self.confirmed_status_height(last_tx_index) - } - - fn resolve_addr(&self, addr: &Addr) -> Result<(OutputType, TypeIndex)> { - let bytes = AddrBytes::from_str(addr)?; - let output_type = OutputType::from(&bytes); - let hash = AddrHash::from(&bytes); - let type_index = self.type_index_for(output_type, &hash)?; - Ok((output_type, type_index)) - } - - /// Lookup the per-type index of an address by `(output_type, hash)`. - /// Returns `UnknownAddr` if the hash is absent from the type's index. - fn type_index_for(&self, output_type: OutputType, hash: &AddrHash) -> Result { - self.indexer() - .stores - .addr_type_to_addr_hash_to_addr_index - .get(output_type) - .data()? - .get(hash)? - .map(|cow| cow.into_owned()) - .ok_or(Error::UnknownAddr) - } -} diff --git a/crates/brk_query/src/impl/addr/activity.rs b/crates/brk_query/src/impl/addr/activity.rs new file mode 100644 index 000000000..024b3203d --- /dev/null +++ b/crates/brk_query/src/impl/addr/activity.rs @@ -0,0 +1,45 @@ +use brk_error::{Error, OptionData, Result}; +use brk_types::{Addr, AddrIndexTxIndex, Height, Txid, Unit}; + +use crate::Query; + +impl Query { + /// Height of the last on-chain activity for an address (last tx_index to height). + /// With `before_txid`, returns the newest activity strictly older than that + /// cursor. Used by paginated chain etags so a new tx above the cursor + /// doesn't invalidate deeper pages. + pub fn addr_last_activity_height( + &self, + addr: &Addr, + before_txid: Option<&Txid>, + ) -> Result { + let (output_type, type_index) = self.resolve_addr(addr)?; + let store = self + .indexer() + .stores + .addr_type_to_addr_index_and_tx_index + .get(output_type) + .data()?; + let tx_index_len = self.safe_lengths().tx_index; + let last_tx_index = match before_txid { + Some(txid) => { + let before_tx_index = self.resolve_tx_index(txid)?; + let min = AddrIndexTxIndex::min_for_addr(type_index); + let cursor = AddrIndexTxIndex::from((type_index, before_tx_index)); + store + .range(min..cursor) + .rev() + .map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index()) + .find(|tx_index| *tx_index < tx_index_len) + .ok_or(Error::UnknownAddr)? + } + None => store + .prefix(type_index) + .rev() + .map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index()) + .find(|tx_index| *tx_index < tx_index_len) + .ok_or(Error::UnknownAddr)?, + }; + self.confirmed_status_height(last_tx_index) + } +} diff --git a/crates/brk_query/src/impl/addr/hash_prefix.rs b/crates/brk_query/src/impl/addr/hash_prefix.rs new file mode 100644 index 000000000..886faac80 --- /dev/null +++ b/crates/brk_query/src/impl/addr/hash_prefix.rs @@ -0,0 +1,115 @@ +use brk_error::{Error, OptionData, Result}; +use brk_types::{Addr, AddrHash, AddrHashPrefixMatches, OutputType}; + +use crate::Query; + +const ADDR_HASH_PREFIX_MATCH_LIMIT: usize = 100; + +impl Query { + pub fn addr_hash_prefix_matches( + &self, + addr_type: OutputType, + prefix: &str, + ) -> Result { + if !addr_type.is_addr() { + return Err(Error::UnsupportedType(addr_type.to_string())); + } + + let prefix = AddrHashPrefix::parse(prefix)?; + let store = self + .indexer() + .stores + .addr_type_to_addr_hash_to_addr_index + .get(addr_type) + .data()?; + let safe_type_index = self.safe_lengths().to_type_index(addr_type); + let addr_readers = self.indexer().vecs.addrs.addr_readers(); + let mut addresses = Vec::new(); + let max_hash = AddrHash::new(u64::MAX); + + if let Some(upper) = prefix.upper { + for (_, type_index) in store.range(prefix.lower..upper) { + if type_index >= safe_type_index { + continue; + } + + let script = addr_readers.script_pubkey(addr_type, type_index); + addresses.push(Addr::try_from((&script, addr_type))?); + + if addresses.len() > ADDR_HASH_PREFIX_MATCH_LIMIT { + break; + } + } + } else { + for (_, type_index) in store.range(prefix.lower..max_hash) { + if type_index >= safe_type_index { + continue; + } + + let script = addr_readers.script_pubkey(addr_type, type_index); + addresses.push(Addr::try_from((&script, addr_type))?); + + if addresses.len() > ADDR_HASH_PREFIX_MATCH_LIMIT { + break; + } + } + + if addresses.len() <= ADDR_HASH_PREFIX_MATCH_LIMIT + && let Some(type_index) = store.get(&max_hash)?.map(|cow| cow.into_owned()) + && type_index < safe_type_index + { + let script = addr_readers.script_pubkey(addr_type, type_index); + addresses.push(Addr::try_from((&script, addr_type))?); + } + } + + let truncated = addresses.len() > ADDR_HASH_PREFIX_MATCH_LIMIT; + addresses.truncate(ADDR_HASH_PREFIX_MATCH_LIMIT); + + Ok(AddrHashPrefixMatches { + addr_type, + prefix: prefix.text, + truncated, + addresses, + }) + } +} + +struct AddrHashPrefix { + text: String, + lower: AddrHash, + upper: Option, +} + +impl AddrHashPrefix { + const MAX_NIBBLES: usize = u64::BITS as usize / 4; + + fn parse(prefix: &str) -> Result { + let nibbles = prefix.len(); + if !(1..=Self::MAX_NIBBLES).contains(&nibbles) { + return Err(Self::parse_error()); + } + + let value = u64::from_str_radix(prefix, 16).map_err(|_| Self::parse_error())?; + let shift = (Self::MAX_NIBBLES - nibbles) * 4; + let factor = 1_u64 << shift; + let lower = value * factor; + let upper = value + .checked_add(1) + .and_then(|value| value.checked_mul(factor)) + .map(AddrHash::new); + + Ok(Self { + text: prefix.to_ascii_lowercase(), + lower: AddrHash::new(lower), + upper, + }) + } + + fn parse_error() -> Error { + Error::Parse(format!( + "hash prefix must be 1 to {} hexadecimal characters", + Self::MAX_NIBBLES + )) + } +} diff --git a/crates/brk_query/src/impl/addr/mempool.rs b/crates/brk_query/src/impl/addr/mempool.rs new file mode 100644 index 000000000..f79dbda8b --- /dev/null +++ b/crates/brk_query/src/impl/addr/mempool.rs @@ -0,0 +1,20 @@ +use std::str::FromStr; + +use brk_error::{Error, Result}; +use brk_types::{Addr, AddrBytes, Transaction}; + +use crate::Query; + +impl Query { + pub fn addr_mempool_hash(&self, addr: &Addr) -> Option { + let mempool = self.mempool()?; + let bytes = AddrBytes::from_str(addr).ok()?; + mempool.addr_state_hash(&bytes) + } + + pub fn addr_mempool_txs(&self, addr: &Addr, limit: usize) -> Result> { + let bytes = AddrBytes::from_str(addr)?; + let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?; + Ok(mempool.addr_txs(&bytes, limit)) + } +} diff --git a/crates/brk_query/src/impl/addr/mod.rs b/crates/brk_query/src/impl/addr/mod.rs new file mode 100644 index 000000000..98fa3cabf --- /dev/null +++ b/crates/brk_query/src/impl/addr/mod.rs @@ -0,0 +1,7 @@ +mod activity; +mod hash_prefix; +mod mempool; +mod resolve; +mod stats; +mod txs; +mod utxos; diff --git a/crates/brk_query/src/impl/addr/resolve.rs b/crates/brk_query/src/impl/addr/resolve.rs new file mode 100644 index 000000000..07e29807c --- /dev/null +++ b/crates/brk_query/src/impl/addr/resolve.rs @@ -0,0 +1,33 @@ +use std::str::FromStr; + +use brk_error::{Error, OptionData, Result}; +use brk_types::{Addr, AddrBytes, AddrHash, OutputType, TypeIndex}; + +use crate::Query; + +impl Query { + pub(super) fn resolve_addr(&self, addr: &Addr) -> Result<(OutputType, TypeIndex)> { + let bytes = AddrBytes::from_str(addr)?; + let output_type = OutputType::from(&bytes); + let hash = AddrHash::from(&bytes); + let type_index = self.type_index_for(output_type, &hash)?; + Ok((output_type, type_index)) + } + + /// Lookup the per-type index of an address by `(output_type, hash)`. + /// Returns `UnknownAddr` if the hash is absent from the type's index. + pub(super) fn type_index_for( + &self, + output_type: OutputType, + hash: &AddrHash, + ) -> Result { + self.indexer() + .stores + .addr_type_to_addr_hash_to_addr_index + .get(output_type) + .data()? + .get(hash)? + .map(|cow| cow.into_owned()) + .ok_or(Error::UnknownAddr) + } +} diff --git a/crates/brk_query/src/impl/addr/stats.rs b/crates/brk_query/src/impl/addr/stats.rs new file mode 100644 index 000000000..45c7eec67 --- /dev/null +++ b/crates/brk_query/src/impl/addr/stats.rs @@ -0,0 +1,78 @@ +use std::str::FromStr; + +use brk_error::{Error, Result}; +use brk_types::{ + Addr, AddrBytes, AddrChainStats, AddrHash, AddrStats, AnyAddrDataIndexEnum, Dollars, + OutputType, TypeIndex, +}; + +use crate::Query; + +impl Query { + pub fn addr(&self, addr: Addr) -> Result { + let bytes = AddrBytes::from_str(&addr)?; + let output_type = OutputType::from(&bytes); + let hash = AddrHash::from(&bytes); + let type_index = self.type_index_for(output_type, &hash)?; + self.addr_stats(addr, bytes, output_type, type_index) + } + + fn addr_stats( + &self, + addr: Addr, + bytes: AddrBytes, + output_type: OutputType, + type_index: TypeIndex, + ) -> Result { + if type_index >= self.safe_lengths().to_type_index(output_type) { + return Err(Error::UnknownAddr); + } + + let computer = self.computer(); + let any_addr_index = computer + .distribution + .any_addr_indexes + .get_once(output_type, type_index)?; + + let (addr_data, realized_price) = match any_addr_index.to_enum() { + AnyAddrDataIndexEnum::Funded(index) => { + let data = computer + .distribution + .addrs_data + .funded + .reader() + .get(usize::from(index)); + let price = data.realized_price().to_dollars(); + (data, price) + } + AnyAddrDataIndexEnum::Empty(index) => { + let data = computer + .distribution + .addrs_data + .empty + .reader() + .get(usize::from(index)) + .into(); + (data, Dollars::default()) + } + }; + + Ok(AddrStats { + addr, + addr_type: output_type, + chain_stats: AddrChainStats { + type_index, + funded_txo_count: addr_data.funded_txo_count, + funded_txo_sum: addr_data.received, + spent_txo_count: addr_data.spent_txo_count, + spent_txo_sum: addr_data.sent, + tx_count: addr_data.tx_count, + realized_price, + }, + mempool_stats: self + .mempool() + .and_then(|m| m.addr_stats(&bytes)) + .unwrap_or_default(), + }) + } +} diff --git a/crates/brk_query/src/impl/addr/txs.rs b/crates/brk_query/src/impl/addr/txs.rs new file mode 100644 index 000000000..6d0598331 --- /dev/null +++ b/crates/brk_query/src/impl/addr/txs.rs @@ -0,0 +1,70 @@ +use brk_error::{OptionData, Result}; +use brk_types::{Addr, AddrIndexTxIndex, Transaction, TxIndex, Txid, Unit}; +use vecdb::VecIndex; + +use crate::Query; + +impl Query { + pub fn addr_txs_chain( + &self, + addr: &Addr, + after_txid: Option, + limit: usize, + ) -> Result> { + let txindices = self.addr_txindices(addr, after_txid, limit)?; + self.transactions_by_indices(&txindices) + } + + pub fn addr_txids( + &self, + addr: Addr, + after_txid: Option, + limit: usize, + ) -> Result> { + let txindices = self.addr_txindices(&addr, after_txid, limit)?; + let txid_reader = self.indexer().vecs.transactions.txid.reader(); + Ok(txindices + .into_iter() + .map(|tx_index| txid_reader.get(tx_index.to_usize())) + .collect()) + } + + fn addr_txindices( + &self, + addr: &Addr, + after_txid: Option, + limit: usize, + ) -> Result> { + let stores = &self.indexer().stores; + + let (output_type, type_index) = self.resolve_addr(addr)?; + + let store = stores + .addr_type_to_addr_index_and_tx_index + .get(output_type) + .data()?; + + let tx_index_len = self.safe_lengths().tx_index; + + if let Some(after_txid) = after_txid { + let after_tx_index = self.resolve_tx_index(&after_txid)?; + let min = AddrIndexTxIndex::min_for_addr(type_index); + let cursor = AddrIndexTxIndex::from((type_index, after_tx_index)); + Ok(store + .range(min..cursor) + .rev() + .map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index()) + .filter(|tx_index| *tx_index < tx_index_len) + .take(limit) + .collect()) + } else { + Ok(store + .prefix(type_index) + .rev() + .map(|(key, _): (AddrIndexTxIndex, Unit)| key.tx_index()) + .filter(|tx_index| *tx_index < tx_index_len) + .take(limit) + .collect()) + } + } +} diff --git a/crates/brk_query/src/impl/addr/utxos.rs b/crates/brk_query/src/impl/addr/utxos.rs new file mode 100644 index 000000000..0aeb8f37f --- /dev/null +++ b/crates/brk_query/src/impl/addr/utxos.rs @@ -0,0 +1,64 @@ +use brk_error::{Error, OptionData, Result}; +use brk_types::{Addr, AddrIndexOutPoint, Height, TxIndex, TxStatus, Unit, Utxo, Vout}; +use vecdb::VecIndex; + +use crate::Query; + +impl Query { + pub fn addr_utxos(&self, addr: Addr, max_utxos: usize) -> Result> { + let indexer = self.indexer(); + let stores = &indexer.stores; + let vecs = &indexer.vecs; + + let (output_type, type_index) = self.resolve_addr(&addr)?; + + let store = stores + .addr_type_to_addr_index_and_unspent_outpoint + .get(output_type) + .data()?; + + let tx_index_len = self.safe_lengths().tx_index; + let outpoints: Vec<(TxIndex, Vout)> = store + .prefix(type_index) + .map(|(key, _): (AddrIndexOutPoint, Unit)| (key.tx_index(), key.vout())) + .filter(|(tx_index, _)| *tx_index < tx_index_len) + .take(max_utxos + 1) + .collect(); + if outpoints.len() > max_utxos { + return Err(Error::TooManyUtxos); + } + + let txid_reader = vecs.transactions.txid.reader(); + let first_txout_index_reader = vecs.transactions.first_txout_index.reader(); + let value_reader = vecs.outputs.value.reader(); + + let mut cached_status: Option<(Height, TxStatus)> = None; + let mut utxos = Vec::with_capacity(outpoints.len()); + + for (tx_index, vout) in outpoints { + let txid = txid_reader.get(tx_index.to_usize()); + let first_txout_index = first_txout_index_reader.get(tx_index.to_usize()); + let value = value_reader.get(usize::from(first_txout_index + vout)); + + let height = self.confirmed_status_height(tx_index)?; + let status = if let Some((h, ref s)) = cached_status + && h == height + { + s.clone() + } else { + let s = self.confirmed_status_at(height)?; + cached_status = Some((height, s.clone())); + s + }; + + utxos.push(Utxo { + txid, + vout, + status, + value, + }); + } + + Ok(utxos) + } +} diff --git a/crates/brk_server/src/api/addrs.rs b/crates/brk_server/src/api/addrs.rs index a9f641776..2affe69c9 100644 --- a/crates/brk_server/src/api/addrs.rs +++ b/crates/brk_server/src/api/addrs.rs @@ -3,12 +3,14 @@ use axum::{ extract::{Path, State}, http::{HeaderMap, Uri}, }; -use brk_types::{AddrStats, AddrValidation, Transaction, Utxo, Version}; +use brk_types::{ + AddrHashPrefixMatches, AddrStats, AddrValidation, Transaction, Utxo, Version, +}; use crate::{ AppState, CacheStrategy, extended::TransformResponseExtended, - params::{AddrAfterTxidParam, AddrParam, Empty, ValidateAddrParam}, + params::{AddrAfterTxidParam, AddrHashPrefixParam, AddrParam, Empty, ValidateAddrParam}, }; /// Esplora `/txs` and `/txs/chain` page sizes. Wire-protocol constants from @@ -26,6 +28,29 @@ pub trait AddrRoutes { impl AddrRoutes for ApiRouter { fn add_addr_routes(self) -> Self { self.api_route( + "/api/address/hash-prefix/{addr_type}/{prefix}", + get_with(async | + uri: Uri, + headers: HeaderMap, + Path(path): Path, + _: Empty, + State(state): State + | { + state.respond_json(&headers, CacheStrategy::Tip, &uri, move |q| { + q.addr_hash_prefix_matches(path.addr_type, &path.prefix) + }).await + }, |op| op + .id("get_address_hash_prefix_matches") + .addrs_tag() + .summary("Address hash-prefix matches") + .description("Find addresses by address type and address-payload hash prefix. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata for the returned addresses through `/api/address/{address}`.") + .json_response::() + .not_modified() + .bad_request() + .server_error() + ), + ) + .api_route( "/api/address/{address}", get_with(async | uri: Uri, diff --git a/crates/brk_server/src/params/addr_hash_prefix_param.rs b/crates/brk_server/src/params/addr_hash_prefix_param.rs new file mode 100644 index 000000000..448ecc454 --- /dev/null +++ b/crates/brk_server/src/params/addr_hash_prefix_param.rs @@ -0,0 +1,9 @@ +use brk_types::OutputType; +use schemars::JsonSchema; +use serde::Deserialize; + +#[derive(Deserialize, JsonSchema)] +pub struct AddrHashPrefixParam { + pub addr_type: OutputType, + pub prefix: String, +} diff --git a/crates/brk_server/src/params/mod.rs b/crates/brk_server/src/params/mod.rs index f95b97c1b..96ef4e0a7 100644 --- a/crates/brk_server/src/params/mod.rs +++ b/crates/brk_server/src/params/mod.rs @@ -1,4 +1,5 @@ mod addr_after_txid_param; +mod addr_hash_prefix_param; mod addr_param; mod block_count_param; mod blockhash_param; @@ -20,6 +21,7 @@ mod urpd_params; mod validate_addr_param; pub use addr_after_txid_param::*; +pub use addr_hash_prefix_param::*; pub use addr_param::*; pub use block_count_param::*; pub use blockhash_param::*; diff --git a/crates/brk_types/src/addr_hash.rs b/crates/brk_types/src/addr_hash.rs index 0334daee9..cce87c417 100644 --- a/crates/brk_types/src/addr_hash.rs +++ b/crates/brk_types/src/addr_hash.rs @@ -7,6 +7,12 @@ use super::AddrBytes; #[derive(Debug, Deref, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Bytes, Hash)] pub struct AddrHash(u64); +impl AddrHash { + pub const fn new(value: u64) -> Self { + Self(value) + } +} + impl From<&AddrBytes> for AddrHash { #[inline] fn from(addr_bytes: &AddrBytes) -> Self { diff --git a/crates/brk_types/src/addr_hash_prefix_matches.rs b/crates/brk_types/src/addr_hash_prefix_matches.rs new file mode 100644 index 000000000..01a49960b --- /dev/null +++ b/crates/brk_types/src/addr_hash_prefix_matches.rs @@ -0,0 +1,11 @@ +use crate::{Addr, OutputType}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct AddrHashPrefixMatches { + pub addr_type: OutputType, + pub prefix: String, + pub truncated: bool, + pub addresses: Vec, +} diff --git a/crates/brk_types/src/lib.rs b/crates/brk_types/src/lib.rs index 83dae08c6..3533f0acd 100644 --- a/crates/brk_types/src/lib.rs +++ b/crates/brk_types/src/lib.rs @@ -6,6 +6,7 @@ mod addr; mod addr_bytes; mod addr_chain_stats; mod addr_hash; +mod addr_hash_prefix_matches; mod addr_index_any; mod addr_index_outpoint; mod addr_index_tx_index; @@ -203,6 +204,7 @@ pub use addr::*; pub use addr_bytes::*; pub use addr_chain_stats::*; pub use addr_hash::*; +pub use addr_hash_prefix_matches::*; pub use addr_index_any::*; pub use addr_index_outpoint::*; pub use addr_index_tx_index::*; diff --git a/modules/brk-client/index.js b/modules/brk-client/index.js index 862cd2ac5..9d1da8222 100644 --- a/modules/brk-client/index.js +++ b/modules/brk-client/index.js @@ -29,6 +29,18 @@ * @property {TypeIndex} typeIndex - Index of this address within its type on the blockchain * @property {Dollars} realizedPrice - Realized price (average cost basis) in USD */ +/** + * @typedef {Object} AddrHashPrefixMatches + * @property {OutputType} addrType + * @property {string} prefix + * @property {boolean} truncated + * @property {Addr[]} addresses + */ +/** + * @typedef {Object} AddrHashPrefixParam + * @property {OutputType} addrType + * @property {string} prefix + */ /** * Address statistics in the mempool (unconfirmed transactions only) * @@ -11476,6 +11488,23 @@ class BrkClient extends BrkClientBase { return this.getJson(path, { signal, onValue, cache }); } + /** + * Address hash-prefix matches + * + * Find addresses by address type and address-payload hash prefix. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata for the returned addresses through `/api/address/{address}`. + * + * Endpoint: `GET /api/address/hash-prefix/{addr_type}/{prefix}` + * + * @param {OutputType} addr_type + * @param {string} prefix + * @param {{ signal?: AbortSignal, onValue?: (value: AddrHashPrefixMatches) => void, cache?: boolean }} [options] + * @returns {Promise} + */ + async getAddressHashPrefixMatches(addr_type, prefix, { signal, onValue, cache } = {}) { + const path = `/api/address/hash-prefix/${addr_type}/${prefix}`; + return this.getJson(path, { signal, onValue, cache }); + } + /** * Address information * diff --git a/packages/brk_client/brk_client/__init__.py b/packages/brk_client/brk_client/__init__.py index 203032e65..8316134bf 100644 --- a/packages/brk_client/brk_client/__init__.py +++ b/packages/brk_client/brk_client/__init__.py @@ -298,6 +298,16 @@ class AddrChainStats(TypedDict): type_index: TypeIndex realized_price: Dollars +class AddrHashPrefixMatches(TypedDict): + addr_type: OutputType + prefix: str + truncated: bool + addresses: List[Addr] + +class AddrHashPrefixParam(TypedDict): + addr_type: OutputType + prefix: str + class AddrMempoolStats(TypedDict): """ Address statistics in the mempool (unconfirmed transactions only) @@ -8436,6 +8446,14 @@ class BrkClient(BrkClientBase): path = f'/api/v1/historical-price{"?" + query if query else ""}' return self.get_json(path) + def get_address_hash_prefix_matches(self, addr_type: OutputType, prefix: str) -> AddrHashPrefixMatches: + """Address hash-prefix matches. + + Find addresses by address type and address-payload hash prefix. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata for the returned addresses through `/api/address/{address}`. + + Endpoint: `GET /api/address/hash-prefix/{addr_type}/{prefix}`""" + return self.get_json(f'/api/address/hash-prefix/{addr_type}/{prefix}') + def get_address(self, address: Addr) -> AddrStats: """Address information. diff --git a/website_next/home/index.js b/website_next/home/index.js index 3d750c647..f097b0696 100644 --- a/website_next/home/index.js +++ b/website_next/home/index.js @@ -2,6 +2,7 @@ const links = [ { href: "/explore", label: "Explore" }, { href: "/learn", label: "Learn" }, { href: "/build", label: "Build" }, + { href: "/wallets", label: "Wallets" }, ]; export function createHomePage() { diff --git a/website_next/index.html b/website_next/index.html index e0c1d060d..36f0c3199 100644 --- a/website_next/index.html +++ b/website_next/index.html @@ -115,6 +115,15 @@ + + + + + + + + +