diff --git a/Cargo.lock b/Cargo.lock index c4bdb83e0..8c7d5de01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -410,6 +410,7 @@ version = "0.3.6" dependencies = [ "brk_cohort", "brk_types", + "rapidhash", "serde", "serde_json", "ureq", @@ -2458,9 +2459,9 @@ dependencies = [ [[package]] name = "rapidhash" -version = "4.4.1" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e48930979c155e2f33aa36ab3119b5ee81332beb6482199a8ecd6029b80b59" +checksum = "32b266a82f4aa99bb5c25e28d11cc44ace63d91adbcbcee4d323e2ae3d49ef37" dependencies = [ "rustversion", ] diff --git a/Cargo.toml b/Cargo.toml index b9fceb02c..04429a237 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -71,6 +71,7 @@ owo-colors = "4.3.0" parking_lot = "0.12.5" pco = "1.0.2" rayon = "1.12.0" +rapidhash = "4.4.2" rustc-hash = "2.1.2" schemars = { version = "1.2.1", features = ["indexmap2"] } serde = "1.0.228" diff --git a/crates/brk_bindgen/src/generators/javascript/client.rs b/crates/brk_bindgen/src/generators/javascript/client.rs index c0fbf5c72..da6238159 100644 --- a/crates/brk_bindgen/src/generators/javascript/client.rs +++ b/crates/brk_bindgen/src/generators/javascript/client.rs @@ -688,6 +688,160 @@ const _p = (prefix, acc) => acc ? `${{prefix}}_${{acc}}` : prefix; "# ) .unwrap(); + output.push_str(r##" +const _MASK_64 = 0xffffffffffffffffn; +const _RAPIDHASH_SECRETS = /** @type {const} */ ([ + 0x2d358dccaa6c78a5n, + 0x8bb84b93962eacc9n, + 0x4b33a62ed433d4a3n, + 0x4d5a2da51de1aa47n, + 0xa0761d6478bd642fn, + 0xe7037ed1a0b428dbn, + 0x90ed1765281c388cn, +]); +const _RAPIDHASH_SEED = _rapidHashSeed(0n); + +/** @param {bigint} value */ +function _u64(value) { + return value & _MASK_64; +} + +/** @param {bigint} left @param {bigint} right */ +function _rapidMix(left, right) { + const result = _u64(left) * _u64(right); + return _u64(result) ^ _u64(result >> 64n); +} + +/** @param {bigint} left @param {bigint} right @returns {[bigint, bigint]} */ +function _rapidMum(left, right) { + const result = _u64(left) * _u64(right); + return [_u64(result), _u64(result >> 64n)]; +} + +/** @param {bigint} seed */ +function _rapidHashSeed(seed) { + return _u64(seed ^ _rapidMix(seed ^ _RAPIDHASH_SECRETS[2], _RAPIDHASH_SECRETS[1])); +} + +/** @param {Uint8Array} bytes @param {number} offset */ +function _readU32(bytes, offset) { + return ( + BigInt(bytes[offset]) | + (BigInt(bytes[offset + 1]) << 8n) | + (BigInt(bytes[offset + 2]) << 16n) | + (BigInt(bytes[offset + 3]) << 24n) + ); +} + +/** @param {Uint8Array} bytes @param {number} offset */ +function _readU64(bytes, offset) { + return _readU32(bytes, offset) | (_readU32(bytes, offset + 4) << 32n); +} + +/** @param {Uint8Array | ArrayBuffer | ArrayBufferView | number[]} payload */ +function _asUint8Array(payload) { + if (payload instanceof Uint8Array) return payload; + if (payload instanceof ArrayBuffer) return new Uint8Array(payload); + if (ArrayBuffer.isView(payload)) return new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength); + if (Array.isArray(payload)) return new Uint8Array(payload); + throw new Error("Expected address payload bytes"); +} + +/** @param {Uint8Array | ArrayBuffer | ArrayBufferView | number[]} payload */ +function _rapidHashV3(payload) { + const bytes = _asUint8Array(payload); + const length = bytes.length; + if (length === 0) throw new Error("Expected a non-empty address payload"); + if (length > 65) throw new Error("Expected at most 65 address payload bytes"); + + let seed = _RAPIDHASH_SEED; + let a = 0n; + let b = 0n; + let remainder; + + if (length <= 16) { + if (length >= 4) { + seed ^= BigInt(length); + if (length >= 8) { + a ^= _readU64(bytes, 0); + b ^= _readU64(bytes, length - 8); + } else { + a ^= _readU32(bytes, 0); + b ^= _readU32(bytes, length - 4); + } + } else if (length > 0) { + a ^= (BigInt(bytes[0]) << 45n) | BigInt(bytes[length - 1]); + b ^= BigInt(bytes[length >> 1]); + } + remainder = BigInt(length); + } else { + seed = _rapidMix(_readU64(bytes, 0) ^ _RAPIDHASH_SECRETS[2], _readU64(bytes, 8) ^ seed); + if (length > 32) { + seed = _rapidMix(_readU64(bytes, 16) ^ _RAPIDHASH_SECRETS[2], _readU64(bytes, 24) ^ seed); + if (length > 48) { + seed = _rapidMix(_readU64(bytes, 32) ^ _RAPIDHASH_SECRETS[1], _readU64(bytes, 40) ^ seed); + if (length > 64) { + seed = _rapidMix(_readU64(bytes, 48) ^ _RAPIDHASH_SECRETS[1], _readU64(bytes, 56) ^ seed); + } + } + } + remainder = BigInt(length); + a ^= _readU64(bytes, length - 16) ^ remainder; + b ^= _readU64(bytes, length - 8); + } + + a ^= _RAPIDHASH_SECRETS[1]; + b ^= seed; + [a, b] = _rapidMum(a, b); + return _rapidMix(a ^ 0xaaaaaaaaaaaaaaaan, b ^ _RAPIDHASH_SECRETS[1] ^ remainder); +} + +/** @param {number} nibbles */ +function _validateHashPrefixNibbles(nibbles) { + if (!Number.isInteger(nibbles) || nibbles < 1 || nibbles > 16) { + throw new Error("Expected hash-prefix length from 1 to 16 hex nibbles"); + } +} + +/** @param {OutputType} addrType @returns {number[]} */ +function _addressPayloadLengths(addrType) { + switch (addrType) { + case "p2a": return [2]; + case "p2pk": return [33, 65]; + case "p2pkh": + case "p2sh": + case "v0_p2wpkh": return [20]; + case "v0_p2wsh": + case "v1_p2tr": return [32]; + default: + throw new Error(`Unsupported address type for address payload hash-prefix: ${addrType}`); + } +} + +/** + * @param {OutputType} addrType + * @param {Uint8Array | ArrayBuffer | ArrayBufferView | number[]} payload + */ +function _validateAddressPayloadForType(addrType, payload) { + const length = _asUint8Array(payload).length; + const expected = _addressPayloadLengths(addrType); + if (!expected.includes(length)) { + throw new Error(`Expected ${addrType} address payload length ${expected.join(" or ")} bytes`); + } +} + +/** + * Compute the RapidHash v3 hash-prefix used by `/api/address/hash-prefix/{addr_type}/{prefix}`. + * @param {Uint8Array | ArrayBuffer | ArrayBufferView | number[]} payload - Raw address payload bytes + * @param {number} nibbles - Prefix length from 1 to 16 hex nibbles + * @returns {string} + */ +function addressPayloadHashPrefix(payload, nibbles) { + _validateHashPrefixNibbles(nibbles); + return _rapidHashV3(payload).toString(16).padStart(16, "0").slice(0, nibbles); +} + +"##); } /// Generate static constants for the BrkClient class. diff --git a/crates/brk_bindgen/src/generators/javascript/tree.rs b/crates/brk_bindgen/src/generators/javascript/tree.rs index c8d9568e9..fad99a769 100644 --- a/crates/brk_bindgen/src/generators/javascript/tree.rs +++ b/crates/brk_bindgen/src/generators/javascript/tree.rs @@ -111,6 +111,32 @@ pub fn generate_main_client( writeln!(output, " this.series = this._buildTree();").unwrap(); writeln!(output, " }}\n").unwrap(); + output.push_str(r##" /** + * Compute the RapidHash v3 hash-prefix for raw address payload bytes. + * @param {Uint8Array | ArrayBuffer | ArrayBufferView | number[]} payload + * @param {number} nibbles + * @returns {string} + */ + static addressPayloadHashPrefix(payload, nibbles) { + return addressPayloadHashPrefix(payload, nibbles); + } + + /** + * Fetch address hash-prefix matches from raw address payload bytes. + * @param {OutputType} addrType + * @param {Uint8Array | ArrayBuffer | ArrayBufferView | number[]} payload - Raw payload bytes matching addrType length + * @param {number} nibbles + * @param {{ signal?: AbortSignal, onValue?: (value: AddrHashPrefixMatches) => void, cache?: boolean }} [options] + * @returns {Promise} + */ + getAddressPayloadHashPrefixMatches(addrType, payload, nibbles, options = {}) { + _validateAddressPayloadForType(addrType, payload); + const prefix = addressPayloadHashPrefix(payload, nibbles); + return this.getAddressHashPrefixMatches(addrType, prefix, options); + } + +"##); + writeln!(output, " /**").unwrap(); writeln!(output, " * @private").unwrap(); writeln!(output, " * @returns {{SeriesTree}}").unwrap(); @@ -161,7 +187,11 @@ pub fn generate_main_client( writeln!(output, "}}\n").unwrap(); - writeln!(output, "export {{ BrkClient, BrkError }};").unwrap(); + writeln!( + output, + "export {{ BrkClient, BrkError, addressPayloadHashPrefix }};" + ) + .unwrap(); } #[allow(clippy::too_many_arguments)] diff --git a/crates/brk_bindgen/src/generators/python/api.rs b/crates/brk_bindgen/src/generators/python/api.rs index 56aee28fc..787f7c4fd 100644 --- a/crates/brk_bindgen/src/generators/python/api.rs +++ b/crates/brk_bindgen/src/generators/python/api.rs @@ -84,6 +84,23 @@ pub fn generate_main_client(output: &mut String, endpoints: &[Endpoint]) { .unwrap(); writeln!(output, " return _date_to_index(index, d)").unwrap(); writeln!(output).unwrap(); + + output.push_str(r#" @staticmethod + def address_payload_hash_prefix(payload: Union[bytes, bytearray, memoryview], nibbles: int) -> str: + """Compute the RapidHash v3 hash-prefix for raw address payload bytes.""" + return address_payload_hash_prefix(payload, nibbles) + + def get_address_payload_hash_prefix_matches( + self, + addr_type: OutputType, + payload: Union[bytes, bytearray, memoryview], + nibbles: int, + ) -> AddrHashPrefixMatches: + """Fetch address hash-prefix matches from raw payload bytes matching addr_type length.""" + _validate_address_payload_for_type(addr_type, payload) + return self.get_address_hash_prefix_matches(addr_type, address_payload_hash_prefix(payload, nibbles)) + +"#); // Generate API methods generate_api_methods(output, endpoints); } diff --git a/crates/brk_bindgen/src/generators/python/client.rs b/crates/brk_bindgen/src/generators/python/client.rs index 0e4c22f8b..c169c248d 100644 --- a/crates/brk_bindgen/src/generators/python/client.rs +++ b/crates/brk_bindgen/src/generators/python/client.rs @@ -147,6 +147,125 @@ def _p(prefix: str, acc: str) -> str: "# ) .unwrap(); + output.push_str(r#" +_MASK_64 = (1 << 64) - 1 +_RAPIDHASH_SECRETS = ( + 0x2d358dccaa6c78a5, + 0x8bb84b93962eacc9, + 0x4b33a62ed433d4a3, + 0x4d5a2da51de1aa47, + 0xa0761d6478bd642f, + 0xe7037ed1a0b428db, + 0x90ed1765281c388c, +) +_RAPIDHASH_SEED = 0 + + +def _u64(value: int) -> int: + return value & _MASK_64 + + +def _rapid_mix(left: int, right: int) -> int: + result = _u64(left) * _u64(right) + return _u64(result) ^ _u64(result >> 64) + + +def _rapid_mum(left: int, right: int) -> Tuple[int, int]: + result = _u64(left) * _u64(right) + return _u64(result), _u64(result >> 64) + + +def _rapid_hash_seed(seed: int) -> int: + return _u64(seed ^ _rapid_mix(seed ^ _RAPIDHASH_SECRETS[2], _RAPIDHASH_SECRETS[1])) + + +_RAPIDHASH_SEED = _rapid_hash_seed(0) + + +def _read_u32(data: bytes, offset: int) -> int: + return int.from_bytes(data[offset:offset + 4], "little") + + +def _read_u64(data: bytes, offset: int) -> int: + return int.from_bytes(data[offset:offset + 8], "little") + + +def _rapid_hash_v3(payload: Union[bytes, bytearray, memoryview]) -> int: + data = bytes(payload) + length = len(data) + if length == 0: + raise ValueError("Expected a non-empty address payload") + if length > 65: + raise ValueError("Expected at most 65 address payload bytes") + + seed = _RAPIDHASH_SEED + a = 0 + b = 0 + + if length <= 16: + if length >= 4: + seed ^= length + if length >= 8: + a ^= _read_u64(data, 0) + b ^= _read_u64(data, length - 8) + else: + a ^= _read_u32(data, 0) + b ^= _read_u32(data, length - 4) + elif length > 0: + a ^= (data[0] << 45) | data[length - 1] + b ^= data[length >> 1] + remainder = length + else: + if length > 16: + seed = _rapid_mix(_read_u64(data, 0) ^ _RAPIDHASH_SECRETS[2], _read_u64(data, 8) ^ seed) + if length > 32: + seed = _rapid_mix(_read_u64(data, 16) ^ _RAPIDHASH_SECRETS[2], _read_u64(data, 24) ^ seed) + if length > 48: + seed = _rapid_mix(_read_u64(data, 32) ^ _RAPIDHASH_SECRETS[1], _read_u64(data, 40) ^ seed) + if length > 64: + seed = _rapid_mix(_read_u64(data, 48) ^ _RAPIDHASH_SECRETS[1], _read_u64(data, 56) ^ seed) + remainder = length + a ^= _read_u64(data, length - 16) ^ remainder + b ^= _read_u64(data, length - 8) + + a ^= _RAPIDHASH_SECRETS[1] + b ^= seed + a, b = _rapid_mum(a, b) + return _rapid_mix(a ^ 0xaaaaaaaaaaaaaaaa, b ^ _RAPIDHASH_SECRETS[1] ^ remainder) + + +def _validate_hash_prefix_nibbles(nibbles: int) -> None: + if isinstance(nibbles, bool) or not isinstance(nibbles, int) or nibbles < 1 or nibbles > 16: + raise ValueError("Expected hash-prefix length from 1 to 16 hex nibbles") + + +def _address_payload_lengths(addr_type: OutputType) -> Tuple[int, ...]: + if addr_type == "p2a": + return (2,) + if addr_type == "p2pk": + return (33, 65) + if addr_type in ("p2pkh", "p2sh", "v0_p2wpkh"): + return (20,) + if addr_type in ("v0_p2wsh", "v1_p2tr"): + return (32,) + raise ValueError(f"Unsupported address type for address payload hash-prefix: {addr_type}") + + +def _validate_address_payload_for_type(addr_type: OutputType, payload: Union[bytes, bytearray, memoryview]) -> None: + length = len(bytes(payload)) + expected = _address_payload_lengths(addr_type) + if length not in expected: + joined = " or ".join(str(value) for value in expected) + raise ValueError(f"Expected {addr_type} address payload length {joined} bytes") + + +def address_payload_hash_prefix(payload: Union[bytes, bytearray, memoryview], nibbles: int) -> str: + """Compute the RapidHash v3 hash-prefix used by `/api/address/hash-prefix/{addr_type}/{prefix}`.""" + _validate_hash_prefix_nibbles(nibbles) + return f"{_rapid_hash_v3(payload):016x}"[:nibbles] + + +"#); } /// Generate the SeriesData and SeriesEndpoint classes diff --git a/crates/brk_bindgen/src/generators/rust/api.rs b/crates/brk_bindgen/src/generators/rust/api.rs index 4884476f2..247fdd931 100644 --- a/crates/brk_bindgen/src/generators/rust/api.rs +++ b/crates/brk_bindgen/src/generators/rust/api.rs @@ -76,6 +76,36 @@ impl BrkClient {{ ) .unwrap(); + output.push_str(r#" /// Decode a mainnet Bitcoin address into the BRK address type and raw payload bytes. + pub fn decode_address_payload(address: &str) -> Result { + decode_address_payload(address) + } + + /// Compute the RapidHash v3 hash-prefix for raw address payload bytes. + pub fn address_payload_hash_prefix(payload: &[u8], nibbles: usize) -> Result { + address_payload_hash_prefix(payload, nibbles) + } + + /// Decode a mainnet Bitcoin address and compute its hash prefix. + pub fn address_hash_prefix(address: &str, nibbles: usize) -> Result { + address_hash_prefix(address, nibbles) + } + + /// Fetch address hash-prefix matches from raw payload bytes matching `addr_type` length. + pub fn get_address_payload_hash_prefix_matches(&self, addr_type: OutputType, payload: &[u8], nibbles: usize) -> Result { + validate_address_payload_for_type(addr_type, payload)?; + let prefix = address_payload_hash_prefix(payload, nibbles)?; + self.get_address_hash_prefix_matches(addr_type, &prefix) + } + + /// Fetch address hash-prefix matches for a mainnet Bitcoin address. + pub fn get_address_hash_prefix_matches_for_address(&self, address: &str, nibbles: usize) -> Result { + let hashed = address_hash_prefix(address, nibbles)?; + self.get_address_hash_prefix_matches(hashed.addr_type, &hashed.prefix) + } + +"#); + generate_api_methods(output, endpoints); writeln!(output, "}}").unwrap(); @@ -118,6 +148,23 @@ fn generate_get_method(output: &mut String, endpoint: &Endpoint) { "get_text" }; + if endpoint.path == "/api/address/hash-prefix/{addr_type}/{prefix}" { + writeln!( + output, + " let addr_type = address_payload_type_path(addr_type)?;" + ) + .unwrap(); + writeln!( + output, + " self.base.{}(&format!(\"{}\"{}))", + fetch_method, path, index_arg + ) + .unwrap(); + writeln!(output, " }}").unwrap(); + writeln!(output).unwrap(); + return; + } + if endpoint.query_params.is_empty() { writeln!( output, diff --git a/crates/brk_bindgen/src/generators/rust/client.rs b/crates/brk_bindgen/src/generators/rust/client.rs index de0b70f7e..45ba155f1 100644 --- a/crates/brk_bindgen/src/generators/rust/client.rs +++ b/crates/brk_bindgen/src/generators/rust/client.rs @@ -11,7 +11,8 @@ use crate::{ pub fn generate_imports(output: &mut String) { writeln!( output, - r#"use std::sync::Arc; + r#"use std::str::FromStr; +use std::sync::Arc; use std::ops::{{Bound, RangeBounds}}; use serde::de::DeserializeOwned; pub use brk_cohort::*; @@ -43,6 +44,97 @@ impl std::error::Error for BrkError {{}} /// Result type for BRK client operations. pub type Result = std::result::Result; +/// BRK address type and raw payload bytes used by the hash-prefix index. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AddressPayload {{ + pub addr_type: OutputType, + pub payload: Vec, +}} + +/// BRK address type and leading hex nibbles of the address-payload hash. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AddressHashPrefix {{ + pub addr_type: OutputType, + pub prefix: String, +}} + +/// Compute the RapidHash v3 hash-prefix used by `/api/address/hash-prefix/{{addr_type}}/{{prefix}}`. +pub fn address_payload_hash_prefix(payload: &[u8], nibbles: usize) -> Result {{ + if payload.is_empty() {{ + return Err(BrkError {{ message: "Expected a non-empty address payload".to_string() }}); + }} + if payload.len() > 65 {{ + return Err(BrkError {{ message: "Expected at most 65 address payload bytes".to_string() }}); + }} + if !(1..=16).contains(&nibbles) {{ + return Err(BrkError {{ message: "Expected hash-prefix length from 1 to 16 hex nibbles".to_string() }}); + }} + Ok(format!("{{:016x}}", rapidhash::v3::rapidhash_v3(payload))[..nibbles].to_string()) +}} + +fn validate_address_payload_for_type(addr_type: OutputType, payload: &[u8]) -> Result<()> {{ + let expected: &[usize] = match addr_type {{ + OutputType::P2A => &[2], + OutputType::P2PK33 => &[33], + OutputType::P2PK65 => &[65], + OutputType::P2PKH | OutputType::P2SH | OutputType::P2WPKH => &[20], + OutputType::P2WSH | OutputType::P2TR => &[32], + OutputType::P2MS | OutputType::OpReturn | OutputType::Empty | OutputType::Unknown => {{ + return Err(BrkError {{ message: format!("Unsupported address type for address payload hash-prefix: {{addr_type:?}}") }}); + }}, + }}; + let addr_type = address_payload_type_path(addr_type)?; + + if !expected.contains(&payload.len()) {{ + let joined = expected + .iter() + .map(ToString::to_string) + .collect::>() + .join(" or "); + return Err(BrkError {{ message: format!("Expected {{addr_type}} address payload length {{joined}} bytes") }}); + }} + + Ok(()) +}} + +fn address_payload_type_path(addr_type: OutputType) -> Result<&'static str> {{ + match addr_type {{ + OutputType::P2A => Ok("p2a"), + OutputType::P2PK33 | OutputType::P2PK65 => Ok("p2pk"), + OutputType::P2PKH => Ok("p2pkh"), + OutputType::P2SH => Ok("p2sh"), + OutputType::P2WPKH => Ok("v0_p2wpkh"), + OutputType::P2WSH => Ok("v0_p2wsh"), + OutputType::P2TR => Ok("v1_p2tr"), + OutputType::P2MS | OutputType::OpReturn | OutputType::Empty | OutputType::Unknown => {{ + Err(BrkError {{ message: format!("Unsupported address type for address payload hash-prefix: {{addr_type:?}}") }}) + }}, + }} +}} + +/// Decode a mainnet Bitcoin address into the BRK address type and raw payload bytes. +pub fn decode_address_payload(address: &str) -> Result {{ + if address.is_empty() {{ + return Err(BrkError {{ message: "Expected an address string".to_string() }}); + }} + let addr_bytes = AddrBytes::from_str(address).map_err(|e| BrkError {{ message: e.to_string() }})?; + let addr_type = OutputType::from(&addr_bytes); + + Ok(AddressPayload {{ + addr_type, + payload: addr_bytes.as_slice().to_vec(), + }}) +}} + +/// Decode a mainnet Bitcoin address and compute its hash prefix. +pub fn address_hash_prefix(address: &str, nibbles: usize) -> Result {{ + let decoded = decode_address_payload(address)?; + Ok(AddressHashPrefix {{ + addr_type: decoded.addr_type, + prefix: address_payload_hash_prefix(&decoded.payload, nibbles)?, + }}) +}} + /// Options for configuring the BRK client. #[derive(Debug, Clone)] pub struct BrkClientOptions {{ diff --git a/crates/brk_client/Cargo.toml b/crates/brk_client/Cargo.toml index fff699733..4c230cb4c 100644 --- a/crates/brk_client/Cargo.toml +++ b/crates/brk_client/Cargo.toml @@ -13,6 +13,7 @@ exclude = ["examples/"] [dependencies] brk_cohort = { workspace = true } brk_types = { workspace = true } +rapidhash = { workspace = true } ureq = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/brk_client/src/lib.rs b/crates/brk_client/src/lib.rs index d97fc8218..f8b461d38 100644 --- a/crates/brk_client/src/lib.rs +++ b/crates/brk_client/src/lib.rs @@ -8,6 +8,7 @@ #![allow(clippy::useless_format)] #![allow(clippy::unnecessary_to_owned)] +use std::str::FromStr; use std::sync::Arc; use std::ops::{Bound, RangeBounds}; use serde::de::DeserializeOwned; @@ -32,6 +33,97 @@ impl std::error::Error for BrkError {} /// Result type for BRK client operations. pub type Result = std::result::Result; +/// BRK address type and raw payload bytes used by the hash-prefix index. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AddressPayload { + pub addr_type: OutputType, + pub payload: Vec, +} + +/// BRK address type and leading hex nibbles of the address-payload hash. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AddressHashPrefix { + pub addr_type: OutputType, + pub prefix: String, +} + +/// Compute the RapidHash v3 hash-prefix used by `/api/address/hash-prefix/{addr_type}/{prefix}`. +pub fn address_payload_hash_prefix(payload: &[u8], nibbles: usize) -> Result { + if payload.is_empty() { + return Err(BrkError { message: "Expected a non-empty address payload".to_string() }); + } + if payload.len() > 65 { + return Err(BrkError { message: "Expected at most 65 address payload bytes".to_string() }); + } + if !(1..=16).contains(&nibbles) { + return Err(BrkError { message: "Expected hash-prefix length from 1 to 16 hex nibbles".to_string() }); + } + Ok(format!("{:016x}", rapidhash::v3::rapidhash_v3(payload))[..nibbles].to_string()) +} + +fn validate_address_payload_for_type(addr_type: OutputType, payload: &[u8]) -> Result<()> { + let expected: &[usize] = match addr_type { + OutputType::P2A => &[2], + OutputType::P2PK33 => &[33], + OutputType::P2PK65 => &[65], + OutputType::P2PKH | OutputType::P2SH | OutputType::P2WPKH => &[20], + OutputType::P2WSH | OutputType::P2TR => &[32], + OutputType::P2MS | OutputType::OpReturn | OutputType::Empty | OutputType::Unknown => { + return Err(BrkError { message: format!("Unsupported address type for address payload hash-prefix: {addr_type:?}") }); + }, + }; + let addr_type = address_payload_type_path(addr_type)?; + + if !expected.contains(&payload.len()) { + let joined = expected + .iter() + .map(ToString::to_string) + .collect::>() + .join(" or "); + return Err(BrkError { message: format!("Expected {addr_type} address payload length {joined} bytes") }); + } + + Ok(()) +} + +fn address_payload_type_path(addr_type: OutputType) -> Result<&'static str> { + match addr_type { + OutputType::P2A => Ok("p2a"), + OutputType::P2PK33 | OutputType::P2PK65 => Ok("p2pk"), + OutputType::P2PKH => Ok("p2pkh"), + OutputType::P2SH => Ok("p2sh"), + OutputType::P2WPKH => Ok("v0_p2wpkh"), + OutputType::P2WSH => Ok("v0_p2wsh"), + OutputType::P2TR => Ok("v1_p2tr"), + OutputType::P2MS | OutputType::OpReturn | OutputType::Empty | OutputType::Unknown => { + Err(BrkError { message: format!("Unsupported address type for address payload hash-prefix: {addr_type:?}") }) + }, + } +} + +/// Decode a mainnet Bitcoin address into the BRK address type and raw payload bytes. +pub fn decode_address_payload(address: &str) -> Result { + if address.is_empty() { + return Err(BrkError { message: "Expected an address string".to_string() }); + } + let addr_bytes = AddrBytes::from_str(address).map_err(|e| BrkError { message: e.to_string() })?; + let addr_type = OutputType::from(&addr_bytes); + + Ok(AddressPayload { + addr_type, + payload: addr_bytes.as_slice().to_vec(), + }) +} + +/// Decode a mainnet Bitcoin address and compute its hash prefix. +pub fn address_hash_prefix(address: &str, nibbles: usize) -> Result { + let decoded = decode_address_payload(address)?; + Ok(AddressHashPrefix { + addr_type: decoded.addr_type, + prefix: address_payload_hash_prefix(&decoded.payload, nibbles)?, + }) +} + /// Options for configuring the BRK client. #[derive(Debug, Clone)] pub struct BrkClientOptions { @@ -9538,7 +9630,7 @@ pub struct BrkClient { impl BrkClient { /// Client version. - pub const VERSION: &'static str = "v0.3.4"; + pub const VERSION: &'static str = "v0.3.6"; /// Create a new client with the given base URL. pub fn new(base_url: impl Into) -> Self { @@ -9592,6 +9684,34 @@ impl BrkClient { )) } + /// Decode a mainnet Bitcoin address into the BRK address type and raw payload bytes. + pub fn decode_address_payload(address: &str) -> Result { + decode_address_payload(address) + } + + /// Compute the RapidHash v3 hash-prefix for raw address payload bytes. + pub fn address_payload_hash_prefix(payload: &[u8], nibbles: usize) -> Result { + address_payload_hash_prefix(payload, nibbles) + } + + /// Decode a mainnet Bitcoin address and compute its hash prefix. + pub fn address_hash_prefix(address: &str, nibbles: usize) -> Result { + address_hash_prefix(address, nibbles) + } + + /// Fetch address hash-prefix matches from raw payload bytes matching `addr_type` length. + pub fn get_address_payload_hash_prefix_matches(&self, addr_type: OutputType, payload: &[u8], nibbles: usize) -> Result { + validate_address_payload_for_type(addr_type, payload)?; + let prefix = address_payload_hash_prefix(payload, nibbles)?; + self.get_address_hash_prefix_matches(addr_type, &prefix) + } + + /// Fetch address hash-prefix matches for a mainnet Bitcoin address. + pub fn get_address_hash_prefix_matches_for_address(&self, address: &str, nibbles: usize) -> Result { + let hashed = address_hash_prefix(address, nibbles)?; + self.get_address_hash_prefix_matches(hashed.addr_type, &hashed.prefix) + } + /// Health check /// /// Liveness probe. Returns server identity, uptime, and indexed/computed heights from local state only (no bitcoind round-trip). For real chain-tip catch-up, see `/api/server/sync`. @@ -9868,10 +9988,11 @@ impl BrkClient { /// 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}`. + /// Find addresses by address type and by the first 1-16 hex nibbles of RapidHash v3 over the raw address payload bytes. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata for the returned addresses through `/api/address/{address}`. /// /// Endpoint: `GET /api/address/hash-prefix/{addr_type}/{prefix}` pub fn get_address_hash_prefix_matches(&self, addr_type: OutputType, prefix: &str) -> Result { + let addr_type = address_payload_type_path(addr_type)?; self.base.get_json(&format!("/api/address/hash-prefix/{addr_type}/{prefix}")) } diff --git a/crates/brk_client/tests/hash_prefix.rs b/crates/brk_client/tests/hash_prefix.rs new file mode 100644 index 000000000..7ca3b96d0 --- /dev/null +++ b/crates/brk_client/tests/hash_prefix.rs @@ -0,0 +1,44 @@ +use brk_client::{ + BrkClient, OutputType, address_hash_prefix, address_payload_hash_prefix, decode_address_payload, +}; + +#[test] +fn address_payload_hash_prefix_vectors() { + let vectors = [ + (vec![0x4e, 0x73], "58101afa51a1ecfd"), + ((0_u8..20).collect::>(), "c3327ecb8ae1ff23"), + ((0_u8..32).collect::>(), "c0186990f026b180"), + ((0_u8..65).collect::>(), "0d4b77027ae7d700"), + ]; + + for (payload, expected) in vectors.iter() { + assert_eq!(address_payload_hash_prefix(payload, 16).unwrap(), *expected); + assert_eq!( + BrkClient::address_payload_hash_prefix(payload, 8).unwrap(), + &expected[..8] + ); + } +} + +#[test] +fn address_payload_hash_prefix_validation() { + assert!(address_payload_hash_prefix(&[], 16).is_err()); + assert!(address_payload_hash_prefix(&[0; 66], 16).is_err()); + assert!(address_payload_hash_prefix(&[1, 2], 0).is_err()); + assert!(address_payload_hash_prefix(&[1, 2], 17).is_err()); +} + +#[test] +fn address_hash_prefix_uses_brk_address_parser() { + let address = "1BoatSLRHtKNngkdXEeobR76b53LETtpyT"; + let decoded = decode_address_payload(address).unwrap(); + assert_eq!(decoded.addr_type, OutputType::P2PKH); + assert_eq!(decoded.payload.len(), 20); + + let hashed = address_hash_prefix(address, 8).unwrap(); + assert_eq!(hashed.addr_type, OutputType::P2PKH); + assert_eq!( + hashed.prefix, + address_payload_hash_prefix(&decoded.payload, 8).unwrap() + ); +} diff --git a/crates/brk_server/src/api/addrs.rs b/crates/brk_server/src/api/addrs.rs index 2affe69c9..393fe6730 100644 --- a/crates/brk_server/src/api/addrs.rs +++ b/crates/brk_server/src/api/addrs.rs @@ -3,9 +3,7 @@ use axum::{ extract::{Path, State}, http::{HeaderMap, Uri}, }; -use brk_types::{ - AddrHashPrefixMatches, AddrStats, AddrValidation, Transaction, Utxo, Version, -}; +use brk_types::{AddrHashPrefixMatches, AddrStats, AddrValidation, Transaction, Utxo, Version}; use crate::{ AppState, CacheStrategy, @@ -43,7 +41,7 @@ impl AddrRoutes for ApiRouter { .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}`.") + .description("Find addresses by address type and by the first 1-16 hex nibbles of RapidHash v3 over the raw address payload bytes. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata for the returned addresses through `/api/address/{address}`.") .json_response::() .not_modified() .bad_request() diff --git a/crates/brk_types/Cargo.toml b/crates/brk_types/Cargo.toml index 0cf77549d..e45f9b89e 100644 --- a/crates/brk_types/Cargo.toml +++ b/crates/brk_types/Cargo.toml @@ -16,7 +16,7 @@ indexmap = { workspace = true } itoa = "1.0.18" jiff = { workspace = true } pco = { workspace = true } -rapidhash = "4.4.1" +rapidhash = { workspace = true } rustc-hash = { workspace = true } ryu = "1.0.23" schemars = { workspace = true } diff --git a/docs/README.md b/docs/README.md index c24da723b..e24a1fa0d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,7 +6,6 @@ [![Supported by OpenSats](https://img.shields.io/badge/supported%20by-opensats-ff7b00)](https://opensats.org/) [![Discord](https://img.shields.io/discord/1350431684562124850?label=Discord&logo=discord&color=5865F2)](https://discord.gg/WACpShCB7M) [![X](https://img.shields.io/badge/@_nym21_-000000?logo=x)](https://x.com/_nym21_) -[![Nostr](https://img.shields.io/badge/Nostr-purple?logo=nostr)](https://primal.net/p/nprofile1qqsfw5dacngjlahye34krvgz7u0yghhjgk7gxzl5ptm9v6n2y3sn03sqxu2e6) > "Shout out to Bitcoin Research Kit. [...] Couldn't recommend them highly enough." > — James Check (CheckOnChain), [What Bitcoin Did #1000](https://www.whatbitcoindid.com/episodes/wbd1000-checkmate) diff --git a/modules/brk-client/docs/classes/BrkClient.md b/modules/brk-client/docs/classes/BrkClient.md index dcf7324c0..aceb1696c 100644 --- a/modules/brk-client/docs/classes/BrkClient.md +++ b/modules/brk-client/docs/classes/BrkClient.md @@ -233,6 +233,32 @@ Defined in: [Developer/brk/modules/brk-client/index.js:1894](https://github.com/ *** +### addressPayloadHashPrefix() + +> `static` **addressPayloadHashPrefix**(`payload`, `nibbles`): `string` + +Compute the RapidHash v3 hash-prefix for raw address payload bytes. + +#### Parameters + +##### payload + +`Uint8Array` | `ArrayBuffer` | `ArrayBufferView` | `number`[] + +Raw address payload bytes. Must be 1 to 65 bytes. + +##### nibbles + +`number` + +Prefix length from 1 to 16 hex nibbles. + +#### Returns + +`string` + +*** + ### dateToIndex() > **dateToIndex**(`index`, `d`): `number` @@ -427,7 +453,7 @@ Defined in: [Developer/brk/modules/brk-client/index.js:11512](https://github.com 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}`. +Find addresses by address type and by the first 1-16 hex nibbles of RapidHash v3 over the raw address payload bytes. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata for the returned addresses through `/api/address/{address}`. Endpoint: `GET /api/address/hash-prefix/{addr_type}/{prefix}` @@ -461,6 +487,50 @@ Endpoint: `GET /api/address/hash-prefix/{addr_type}/{prefix}` *** +### getAddressPayloadHashPrefixMatches() + +> **getAddressPayloadHashPrefixMatches**(`addrType`, `payload`, `nibbles`, `options?`): `Promise`\<[`AddrHashPrefixMatches`](../interfaces/AddrHashPrefixMatches.md)\> + +Fetch address hash-prefix matches from raw payload bytes matching `addrType` length. + +#### Parameters + +##### addrType + +[`OutputType`](../type-aliases/OutputType.md) + +##### payload + +`Uint8Array` | `ArrayBuffer` | `ArrayBufferView` | `number`[] + +Raw payload bytes matching `addrType` length. + +##### nibbles + +`number` + +Prefix length from 1 to 16 hex nibbles. + +##### options? + +###### cache? + +`boolean` + +###### onValue? + +(`value`) => `void` + +###### signal? + +`AbortSignal` + +#### Returns + +`Promise`\<[`AddrHashPrefixMatches`](../interfaces/AddrHashPrefixMatches.md)\> + +*** + ### getAddressMempoolTxs() > **getAddressMempoolTxs**(`address`, `options?`): `Promise`\<[`Transaction`](../interfaces/Transaction.md)[]\> diff --git a/modules/brk-client/docs/functions/addressPayloadHashPrefix.md b/modules/brk-client/docs/functions/addressPayloadHashPrefix.md new file mode 100644 index 000000000..89a20574b --- /dev/null +++ b/modules/brk-client/docs/functions/addressPayloadHashPrefix.md @@ -0,0 +1,30 @@ +[**brk-client**](../README.md) + +*** + +[brk-client](../globals.md) / addressPayloadHashPrefix + +# Function: addressPayloadHashPrefix() + +> **addressPayloadHashPrefix**(`payload`, `nibbles`): `string` + +Compute the RapidHash v3 hash-prefix used by `/api/address/hash-prefix/{addr_type}/{prefix}`. + +## Parameters + +### payload + +`Uint8Array` | `ArrayBuffer` | `ArrayBufferView` | `number`[] + +Raw address payload bytes. Must be 1 to 65 bytes. + +### nibbles + +`number` + +Prefix length from 1 to 16 hex nibbles. + +## Returns + +`string` + diff --git a/modules/brk-client/docs/globals.md b/modules/brk-client/docs/globals.md index 9b48b92da..0518149fe 100644 --- a/modules/brk-client/docs/globals.md +++ b/modules/brk-client/docs/globals.md @@ -9,6 +9,10 @@ - [BrkClient](classes/BrkClient.md) - [BrkError](classes/BrkError.md) +## Functions + +- [addressPayloadHashPrefix](functions/addressPayloadHashPrefix.md) + ## Interfaces - [\_0sdM0M1M1sdM2M2sdM3sdP0P1P1sdP2P2sdP3sdSdZscorePattern](interfaces/0sdM0M1M1sdM2M2sdM3sdP0P1P1sdP2P2sdP3sdSdZscorePattern.md) diff --git a/modules/brk-client/index.js b/modules/brk-client/index.js index 9fdd897c5..07a9201ef 100644 --- a/modules/brk-client/index.js +++ b/modules/brk-client/index.js @@ -2132,6 +2132,159 @@ const _m = (acc, s) => s ? (acc ? `${acc}_${s}` : s) : acc; const _p = (prefix, acc) => acc ? `${prefix}_${acc}` : prefix; + +const _MASK_64 = 0xffffffffffffffffn; +const _RAPIDHASH_SECRETS = /** @type {const} */ ([ + 0x2d358dccaa6c78a5n, + 0x8bb84b93962eacc9n, + 0x4b33a62ed433d4a3n, + 0x4d5a2da51de1aa47n, + 0xa0761d6478bd642fn, + 0xe7037ed1a0b428dbn, + 0x90ed1765281c388cn, +]); +const _RAPIDHASH_SEED = _rapidHashSeed(0n); + +/** @param {bigint} value */ +function _u64(value) { + return value & _MASK_64; +} + +/** @param {bigint} left @param {bigint} right */ +function _rapidMix(left, right) { + const result = _u64(left) * _u64(right); + return _u64(result) ^ _u64(result >> 64n); +} + +/** @param {bigint} left @param {bigint} right @returns {[bigint, bigint]} */ +function _rapidMum(left, right) { + const result = _u64(left) * _u64(right); + return [_u64(result), _u64(result >> 64n)]; +} + +/** @param {bigint} seed */ +function _rapidHashSeed(seed) { + return _u64(seed ^ _rapidMix(seed ^ _RAPIDHASH_SECRETS[2], _RAPIDHASH_SECRETS[1])); +} + +/** @param {Uint8Array} bytes @param {number} offset */ +function _readU32(bytes, offset) { + return ( + BigInt(bytes[offset]) | + (BigInt(bytes[offset + 1]) << 8n) | + (BigInt(bytes[offset + 2]) << 16n) | + (BigInt(bytes[offset + 3]) << 24n) + ); +} + +/** @param {Uint8Array} bytes @param {number} offset */ +function _readU64(bytes, offset) { + return _readU32(bytes, offset) | (_readU32(bytes, offset + 4) << 32n); +} + +/** @param {Uint8Array | ArrayBuffer | ArrayBufferView | number[]} payload */ +function _asUint8Array(payload) { + if (payload instanceof Uint8Array) return payload; + if (payload instanceof ArrayBuffer) return new Uint8Array(payload); + if (ArrayBuffer.isView(payload)) return new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength); + if (Array.isArray(payload)) return new Uint8Array(payload); + throw new Error("Expected address payload bytes"); +} + +/** @param {Uint8Array | ArrayBuffer | ArrayBufferView | number[]} payload */ +function _rapidHashV3(payload) { + const bytes = _asUint8Array(payload); + const length = bytes.length; + if (length === 0) throw new Error("Expected a non-empty address payload"); + if (length > 65) throw new Error("Expected at most 65 address payload bytes"); + + let seed = _RAPIDHASH_SEED; + let a = 0n; + let b = 0n; + let remainder; + + if (length <= 16) { + if (length >= 4) { + seed ^= BigInt(length); + if (length >= 8) { + a ^= _readU64(bytes, 0); + b ^= _readU64(bytes, length - 8); + } else { + a ^= _readU32(bytes, 0); + b ^= _readU32(bytes, length - 4); + } + } else if (length > 0) { + a ^= (BigInt(bytes[0]) << 45n) | BigInt(bytes[length - 1]); + b ^= BigInt(bytes[length >> 1]); + } + remainder = BigInt(length); + } else { + seed = _rapidMix(_readU64(bytes, 0) ^ _RAPIDHASH_SECRETS[2], _readU64(bytes, 8) ^ seed); + if (length > 32) { + seed = _rapidMix(_readU64(bytes, 16) ^ _RAPIDHASH_SECRETS[2], _readU64(bytes, 24) ^ seed); + if (length > 48) { + seed = _rapidMix(_readU64(bytes, 32) ^ _RAPIDHASH_SECRETS[1], _readU64(bytes, 40) ^ seed); + if (length > 64) { + seed = _rapidMix(_readU64(bytes, 48) ^ _RAPIDHASH_SECRETS[1], _readU64(bytes, 56) ^ seed); + } + } + } + remainder = BigInt(length); + a ^= _readU64(bytes, length - 16) ^ remainder; + b ^= _readU64(bytes, length - 8); + } + + a ^= _RAPIDHASH_SECRETS[1]; + b ^= seed; + [a, b] = _rapidMum(a, b); + return _rapidMix(a ^ 0xaaaaaaaaaaaaaaaan, b ^ _RAPIDHASH_SECRETS[1] ^ remainder); +} + +/** @param {number} nibbles */ +function _validateHashPrefixNibbles(nibbles) { + if (!Number.isInteger(nibbles) || nibbles < 1 || nibbles > 16) { + throw new Error("Expected hash-prefix length from 1 to 16 hex nibbles"); + } +} + +/** @param {OutputType} addrType @returns {number[]} */ +function _addressPayloadLengths(addrType) { + switch (addrType) { + case "p2a": return [2]; + case "p2pk": return [33, 65]; + case "p2pkh": + case "p2sh": + case "v0_p2wpkh": return [20]; + case "v0_p2wsh": + case "v1_p2tr": return [32]; + default: + throw new Error(`Unsupported address type for address payload hash-prefix: ${addrType}`); + } +} + +/** + * @param {OutputType} addrType + * @param {Uint8Array | ArrayBuffer | ArrayBufferView | number[]} payload + */ +function _validateAddressPayloadForType(addrType, payload) { + const length = _asUint8Array(payload).length; + const expected = _addressPayloadLengths(addrType); + if (!expected.includes(length)) { + throw new Error(`Expected ${addrType} address payload length ${expected.join(" or ")} bytes`); + } +} + +/** + * Compute the RapidHash v3 hash-prefix used by `/api/address/hash-prefix/{addr_type}/{prefix}`. + * @param {Uint8Array | ArrayBuffer | ArrayBufferView | number[]} payload - Raw address payload bytes + * @param {number} nibbles - Prefix length from 1 to 16 hex nibbles + * @returns {string} + */ +function addressPayloadHashPrefix(payload, nibbles) { + _validateHashPrefixNibbles(nibbles); + return _rapidHashV3(payload).toString(16).padStart(16, "0").slice(0, nibbles); +} + // Index group constants and factory const _i1 = /** @type {const} */ (["minute10", "minute30", "hour1", "hour4", "hour12", "day1", "day3", "week1", "month1", "month3", "month6", "year1", "year10", "halving", "epoch", "height"]); @@ -9091,6 +9244,30 @@ class BrkClient extends BrkClientBase { this.series = this._buildTree(); } + /** + * Compute the RapidHash v3 hash-prefix for raw address payload bytes. + * @param {Uint8Array | ArrayBuffer | ArrayBufferView | number[]} payload + * @param {number} nibbles + * @returns {string} + */ + static addressPayloadHashPrefix(payload, nibbles) { + return addressPayloadHashPrefix(payload, nibbles); + } + + /** + * Fetch address hash-prefix matches from raw address payload bytes. + * @param {OutputType} addrType + * @param {Uint8Array | ArrayBuffer | ArrayBufferView | number[]} payload - Raw payload bytes matching addrType length + * @param {number} nibbles + * @param {{ signal?: AbortSignal, onValue?: (value: AddrHashPrefixMatches) => void, cache?: boolean }} [options] + * @returns {Promise} + */ + getAddressPayloadHashPrefixMatches(addrType, payload, nibbles, options = {}) { + _validateAddressPayloadForType(addrType, payload); + const prefix = addressPayloadHashPrefix(payload, nibbles); + return this.getAddressHashPrefixMatches(addrType, prefix, options); + } + /** * @private * @returns {SeriesTree} @@ -11500,7 +11677,7 @@ class BrkClient extends BrkClientBase { /** * 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}`. + * Find addresses by address type and by the first 1-16 hex nibbles of RapidHash v3 over the raw address payload bytes. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata for the returned addresses through `/api/address/{address}`. * * Endpoint: `GET /api/address/hash-prefix/{addr_type}/{prefix}` * @@ -12766,4 +12943,4 @@ class BrkClient extends BrkClientBase { } -export { BrkClient, BrkError }; +export { BrkClient, BrkError, addressPayloadHashPrefix }; diff --git a/modules/brk-client/package.json b/modules/brk-client/package.json index 025754115..5e3364fd7 100644 --- a/modules/brk-client/package.json +++ b/modules/brk-client/package.json @@ -5,6 +5,7 @@ "scripts": { "test": "node tests/basic.js && node tests/tree.js", "test:basic": "node tests/basic.js", + "test:hash-prefix": "node tests/hash_prefix.js", "test:tree": "node tests/tree.js" }, "description": "Bitcoin on-chain analytics client — thousands of metrics, block explorer, and address index", diff --git a/modules/brk-client/tests/hash_prefix.js b/modules/brk-client/tests/hash_prefix.js new file mode 100644 index 000000000..c0778871c --- /dev/null +++ b/modules/brk-client/tests/hash_prefix.js @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import { BrkClient, addressPayloadHashPrefix } from "../index.js"; + +const vectors = [ + [Uint8Array.of(0x4e, 0x73), "58101afa51a1ecfd"], + [Uint8Array.from({ length: 20 }, (_, i) => i), "c3327ecb8ae1ff23"], + [Uint8Array.from({ length: 32 }, (_, i) => i), "c0186990f026b180"], + [Uint8Array.from({ length: 65 }, (_, i) => i), "0d4b77027ae7d700"], +]; + +for (const [payload, hash] of vectors) { + assert.equal(addressPayloadHashPrefix(payload, 16), hash); + assert.equal(BrkClient.addressPayloadHashPrefix(payload, 8), hash.slice(0, 8)); +} + +assert.throws(() => addressPayloadHashPrefix(Uint8Array.of(), 16), /non-empty/); +assert.throws(() => addressPayloadHashPrefix(new Uint8Array(66), 16), /at most 65/); +assert.throws(() => addressPayloadHashPrefix(Uint8Array.of(1, 2), 0), /1 to 16/); + +const client = new BrkClient("http://127.0.0.1:0"); +client.getAddressHashPrefixMatches = (addrType, prefix) => ({ addrType, prefix, truncated: false, addresses: [] }); + +assert.deepEqual( + client.getAddressPayloadHashPrefixMatches("p2pkh", Uint8Array.from({ length: 20 }, (_, i) => i), 8), + { addrType: "p2pkh", prefix: "c3327ecb", truncated: false, addresses: [] }, +); +assert.throws( + () => client.getAddressPayloadHashPrefixMatches("p2pkh", Uint8Array.of(1, 2), 8), + /p2pkh address payload length 20 bytes/, +); +assert.throws( + () => client.getAddressPayloadHashPrefixMatches("op_return", Uint8Array.of(1, 2), 8), + /Unsupported address type/, +); diff --git a/packages/brk_client/DOCS.md b/packages/brk_client/DOCS.md index a3a7d30dd..80ff24965 100644 --- a/packages/brk_client/DOCS.md +++ b/packages/brk_client/DOCS.md @@ -24,6 +24,7 @@ * [series\_endpoint](#brk_client.BrkClient.series_endpoint) * [index\_to\_date](#brk_client.BrkClient.index_to_date) * [date\_to\_index](#brk_client.BrkClient.date_to_index) + * [address\_payload\_hash\_prefix](#brk_client.BrkClient.address_payload_hash_prefix) * [get\_health](#brk_client.BrkClient.get_health) * [get\_version](#brk_client.BrkClient.get_version) * [get\_sync\_status](#brk_client.BrkClient.get_sync_status) @@ -47,6 +48,7 @@ * [get\_difficulty\_adjustment](#brk_client.BrkClient.get_difficulty_adjustment) * [get\_prices](#brk_client.BrkClient.get_prices) * [get\_historical\_price](#brk_client.BrkClient.get_historical_price) + * [get\_address\_payload\_hash\_prefix\_matches](#brk_client.BrkClient.get_address_payload_hash_prefix_matches) * [get\_address\_hash\_prefix\_matches](#brk_client.BrkClient.get_address_hash_prefix_matches) * [get\_address](#brk_client.BrkClient.get_address) * [get\_address\_txs](#brk_client.BrkClient.get_address_txs) @@ -255,6 +257,17 @@ def date_to_index(index: Index, d: Union[date, datetime]) -> int Convert a date/datetime to an index value for date-based indexes. + + +#### address\_payload\_hash\_prefix + +```python +def address_payload_hash_prefix( + payload: Union[bytes, bytearray, memoryview], nibbles: int) -> str +``` + +Compute the RapidHash v3 hash-prefix for raw address payload bytes. + #### get\_health @@ -607,6 +620,19 @@ Get historical BTC/USD price. Optionally specify a UNIX timestamp to get the pri Endpoint: `GET /api/v1/historical-price` + + +#### get\_address\_payload\_hash\_prefix\_matches + +```python +def get_address_payload_hash_prefix_matches( + addr_type: OutputType, + payload: Union[bytes, bytearray, memoryview], + nibbles: int) -> AddrHashPrefixMatches +``` + +Fetch address hash-prefix matches from raw payload bytes matching `addr_type` length. + #### get\_address\_hash\_prefix\_matches @@ -618,7 +644,7 @@ def get_address_hash_prefix_matches(addr_type: OutputType, 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}`. +Find addresses by address type and by the first 1-16 hex nibbles of RapidHash v3 over the raw address payload bytes. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata for the returned addresses through `/api/address/{address}`. Endpoint: `GET /api/address/hash-prefix/{addr_type}/{prefix}` @@ -1769,4 +1795,3 @@ Compact OpenAPI specification. Compact OpenAPI specification optimized for LLM consumption. Removes redundant fields while preserving essential API information. Full spec available at `/openapi.json`. Endpoint: `GET /api.json` - diff --git a/packages/brk_client/brk_client/__init__.py b/packages/brk_client/brk_client/__init__.py index efd37cf5f..d128c713d 100644 --- a/packages/brk_client/brk_client/__init__.py +++ b/packages/brk_client/brk_client/__init__.py @@ -1912,6 +1912,124 @@ def _p(prefix: str, acc: str) -> str: return f"{prefix}_{acc}" if acc else prefix + +_MASK_64 = (1 << 64) - 1 +_RAPIDHASH_SECRETS = ( + 0x2d358dccaa6c78a5, + 0x8bb84b93962eacc9, + 0x4b33a62ed433d4a3, + 0x4d5a2da51de1aa47, + 0xa0761d6478bd642f, + 0xe7037ed1a0b428db, + 0x90ed1765281c388c, +) +_RAPIDHASH_SEED = 0 + + +def _u64(value: int) -> int: + return value & _MASK_64 + + +def _rapid_mix(left: int, right: int) -> int: + result = _u64(left) * _u64(right) + return _u64(result) ^ _u64(result >> 64) + + +def _rapid_mum(left: int, right: int) -> Tuple[int, int]: + result = _u64(left) * _u64(right) + return _u64(result), _u64(result >> 64) + + +def _rapid_hash_seed(seed: int) -> int: + return _u64(seed ^ _rapid_mix(seed ^ _RAPIDHASH_SECRETS[2], _RAPIDHASH_SECRETS[1])) + + +_RAPIDHASH_SEED = _rapid_hash_seed(0) + + +def _read_u32(data: bytes, offset: int) -> int: + return int.from_bytes(data[offset:offset + 4], "little") + + +def _read_u64(data: bytes, offset: int) -> int: + return int.from_bytes(data[offset:offset + 8], "little") + + +def _rapid_hash_v3(payload: Union[bytes, bytearray, memoryview]) -> int: + data = bytes(payload) + length = len(data) + if length == 0: + raise ValueError("Expected a non-empty address payload") + if length > 65: + raise ValueError("Expected at most 65 address payload bytes") + + seed = _RAPIDHASH_SEED + a = 0 + b = 0 + + if length <= 16: + if length >= 4: + seed ^= length + if length >= 8: + a ^= _read_u64(data, 0) + b ^= _read_u64(data, length - 8) + else: + a ^= _read_u32(data, 0) + b ^= _read_u32(data, length - 4) + elif length > 0: + a ^= (data[0] << 45) | data[length - 1] + b ^= data[length >> 1] + remainder = length + else: + if length > 16: + seed = _rapid_mix(_read_u64(data, 0) ^ _RAPIDHASH_SECRETS[2], _read_u64(data, 8) ^ seed) + if length > 32: + seed = _rapid_mix(_read_u64(data, 16) ^ _RAPIDHASH_SECRETS[2], _read_u64(data, 24) ^ seed) + if length > 48: + seed = _rapid_mix(_read_u64(data, 32) ^ _RAPIDHASH_SECRETS[1], _read_u64(data, 40) ^ seed) + if length > 64: + seed = _rapid_mix(_read_u64(data, 48) ^ _RAPIDHASH_SECRETS[1], _read_u64(data, 56) ^ seed) + remainder = length + a ^= _read_u64(data, length - 16) ^ remainder + b ^= _read_u64(data, length - 8) + + a ^= _RAPIDHASH_SECRETS[1] + b ^= seed + a, b = _rapid_mum(a, b) + return _rapid_mix(a ^ 0xaaaaaaaaaaaaaaaa, b ^ _RAPIDHASH_SECRETS[1] ^ remainder) + + +def _validate_hash_prefix_nibbles(nibbles: int) -> None: + if isinstance(nibbles, bool) or not isinstance(nibbles, int) or nibbles < 1 or nibbles > 16: + raise ValueError("Expected hash-prefix length from 1 to 16 hex nibbles") + + +def _address_payload_lengths(addr_type: OutputType) -> Tuple[int, ...]: + if addr_type == "p2a": + return (2,) + if addr_type == "p2pk": + return (33, 65) + if addr_type in ("p2pkh", "p2sh", "v0_p2wpkh"): + return (20,) + if addr_type in ("v0_p2wsh", "v1_p2tr"): + return (32,) + raise ValueError(f"Unsupported address type for address payload hash-prefix: {addr_type}") + + +def _validate_address_payload_for_type(addr_type: OutputType, payload: Union[bytes, bytearray, memoryview]) -> None: + length = len(bytes(payload)) + expected = _address_payload_lengths(addr_type) + if length not in expected: + joined = " or ".join(str(value) for value in expected) + raise ValueError(f"Expected {addr_type} address payload length {joined} bytes") + + +def address_payload_hash_prefix(payload: Union[bytes, bytearray, memoryview], nibbles: int) -> str: + """Compute the RapidHash v3 hash-prefix used by `/api/address/hash-prefix/{addr_type}/{prefix}`.""" + _validate_hash_prefix_nibbles(nibbles) + return f"{_rapid_hash_v3(payload):016x}"[:nibbles] + + # Date conversion constants _GENESIS = date(2009, 1, 3) # day1 0, week1 0 _DAY_ONE = date(2009, 1, 9) # day1 1 (6 day gap after genesis) @@ -8201,6 +8319,21 @@ class BrkClient(BrkClientBase): """Convert a date/datetime to an index value for date-based indexes.""" return _date_to_index(index, d) + @staticmethod + def address_payload_hash_prefix(payload: Union[bytes, bytearray, memoryview], nibbles: int) -> str: + """Compute the RapidHash v3 hash-prefix for raw address payload bytes.""" + return address_payload_hash_prefix(payload, nibbles) + + def get_address_payload_hash_prefix_matches( + self, + addr_type: OutputType, + payload: Union[bytes, bytearray, memoryview], + nibbles: int, + ) -> AddrHashPrefixMatches: + """Fetch address hash-prefix matches from raw payload bytes matching addr_type length.""" + _validate_address_payload_for_type(addr_type, payload) + return self.get_address_hash_prefix_matches(addr_type, address_payload_hash_prefix(payload, nibbles)) + def get_health(self) -> Health: """Health check. @@ -8449,7 +8582,7 @@ class BrkClient(BrkClientBase): def get_address_hash_prefix_matches(self, addr_type: OutputType, prefix: str) -> AddrHashPrefixMatches: """Address hash-prefix matches. - Find addresses by address type and 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}`. + Find addresses by address type and by the first 1-16 hex nibbles of RapidHash v3 over the raw address payload bytes. Intended for privacy-preserving client-side wallet discovery without sending raw addresses or xpubs. Fetch metadata for the returned addresses through `/api/address/{address}`. Endpoint: `GET /api/address/hash-prefix/{addr_type}/{prefix}`""" return self.get_json(f'/api/address/hash-prefix/{addr_type}/{prefix}') @@ -9163,4 +9296,3 @@ class BrkClient(BrkClientBase): Endpoint: `GET /api.json`""" return self.get_json('/api.json') - diff --git a/packages/brk_client/tests/test_hash_prefix.py b/packages/brk_client/tests/test_hash_prefix.py new file mode 100644 index 000000000..bbe2b8c14 --- /dev/null +++ b/packages/brk_client/tests/test_hash_prefix.py @@ -0,0 +1,48 @@ +import pytest + +from brk_client import BrkClient, address_payload_hash_prefix + + +VECTORS = ( + (bytes([0x4E, 0x73]), "58101afa51a1ecfd"), + (bytes(range(20)), "c3327ecb8ae1ff23"), + (bytes(range(32)), "c0186990f026b180"), + (bytes(range(65)), "0d4b77027ae7d700"), +) + + +def test_address_payload_hash_prefix_vectors(): + for payload, expected in VECTORS: + assert address_payload_hash_prefix(payload, 16) == expected + assert BrkClient.address_payload_hash_prefix(payload, 8) == expected[:8] + + +def test_address_payload_hash_prefix_validation(): + with pytest.raises(ValueError, match="non-empty"): + address_payload_hash_prefix(b"", 16) + with pytest.raises(ValueError, match="at most 65"): + address_payload_hash_prefix(bytes(range(66)), 16) + with pytest.raises(ValueError, match="1 to 16"): + address_payload_hash_prefix(b"\x01\x02", 0) + + +def test_address_payload_hash_prefix_match_validation(): + client = BrkClient("http://127.0.0.1:0") + client.get_address_hash_prefix_matches = lambda addr_type, prefix: { + "addr_type": addr_type, + "prefix": prefix, + "truncated": False, + "addresses": [], + } + + assert client.get_address_payload_hash_prefix_matches("p2pkh", bytes(range(20)), 8) == { + "addr_type": "p2pkh", + "prefix": "c3327ecb", + "truncated": False, + "addresses": [], + } + + with pytest.raises(ValueError, match="p2pkh address payload length 20 bytes"): + client.get_address_payload_hash_prefix_matches("p2pkh", b"\x01\x02", 8) + with pytest.raises(ValueError, match="Unsupported address type"): + client.get_address_payload_hash_prefix_matches("op_return", b"\x01\x02", 8) diff --git a/website_next/wallets/lookup/bucket.js b/website_next/wallets/lookup/bucket.js index 9fe51135e..3842fa68d 100644 --- a/website_next/wallets/lookup/bucket.js +++ b/website_next/wallets/lookup/bucket.js @@ -1,24 +1,15 @@ -import { rapidHashV3Prefix } from "./hash.js"; - const MIN_PREFIX_NIBBLES = 4; const MAX_PREFIX_NIBBLES = 16; /** + * @typedef {import("../../modules/brk-client/index.js").AddrHashPrefixMatches} AddrHashPrefixMatches * @typedef {import("../derive/index.js").AddressType} AddressType * @typedef {import("../derive/index.js").GeneratedAddress} GeneratedAddress */ -/** - * @typedef {Object} AddrHashPrefixMatches - * @property {AddressType} addrType - * @property {string} prefix - * @property {boolean} truncated - * @property {string[]} addresses - */ - /** * @typedef {Object} AddressClient - * @property {(addrType: AddressType, prefix: string, options?: { cache?: boolean }) => Promise} getAddressHashPrefixMatches + * @property {(addrType: AddressType, payload: Uint8Array, nibbles: number, options?: { cache?: boolean }) => Promise} getAddressPayloadHashPrefixMatches */ /** @@ -28,10 +19,8 @@ const MAX_PREFIX_NIBBLES = 16; * @returns {Promise} */ async function fetchPrefixMatches(client, generated, nibbles) { - const prefix = rapidHashV3Prefix(generated.payload, nibbles); - return /** @type {AddrHashPrefixMatches} */ ( - await client.getAddressHashPrefixMatches(generated.addrType, prefix, { + await client.getAddressPayloadHashPrefixMatches(generated.addrType, generated.payload, nibbles, { cache: false, }) ); diff --git a/website_next/wallets/lookup/hash.js b/website_next/wallets/lookup/hash.js deleted file mode 100644 index 8cb007913..000000000 --- a/website_next/wallets/lookup/hash.js +++ /dev/null @@ -1,107 +0,0 @@ -const MASK_64 = 0xffffffffffffffffn; -const DEFAULT_SECRETS = /** @type {const} */ ([ - 0x2d358dccaa6c78a5n, - 0x8bb84b93962eacc9n, - 0x4b33a62ed433d4a3n, - 0x4d5a2da51de1aa47n, - 0xa0761d6478bd642fn, - 0xe7037ed1a0b428dbn, - 0x90ed1765281c388cn, -]); -const DEFAULT_SEED = rapidHashSeed(0n); - -/** - * @param {bigint} value - */ -function u64(value) { - return value & MASK_64; -} - -/** - * @param {bigint} left - * @param {bigint} right - */ -function rapidMix(left, right) { - const result = u64(left) * u64(right); - - return u64(result) ^ u64(result >> 64n); -} - -/** - * @param {bigint} left - * @param {bigint} right - * @returns {[bigint, bigint]} - */ -function rapidMum(left, right) { - const result = u64(left) * u64(right); - - return [u64(result), u64(result >> 64n)]; -} - -/** - * @param {bigint} seed - */ -function rapidHashSeed(seed) { - return u64(seed ^ rapidMix(seed ^ DEFAULT_SECRETS[2], DEFAULT_SECRETS[1])); -} - -/** - * @param {Uint8Array} bytes - * @param {number} offset - */ -function readU64(bytes, offset) { - return ( - BigInt(bytes[offset]) | - (BigInt(bytes[offset + 1]) << 8n) | - (BigInt(bytes[offset + 2]) << 16n) | - (BigInt(bytes[offset + 3]) << 24n) | - (BigInt(bytes[offset + 4]) << 32n) | - (BigInt(bytes[offset + 5]) << 40n) | - (BigInt(bytes[offset + 6]) << 48n) | - (BigInt(bytes[offset + 7]) << 56n) - ); -} - -/** - * @param {Uint8Array} bytes - */ -function rapidHashV3(bytes) { - const length = bytes.length; - - if (length <= 16) { - throw new Error("Expected more than 16 bytes"); - } - - if (length > 32) { - throw new Error("Expected at most 32 bytes"); - } - - let seed = rapidMix( - readU64(bytes, 0) ^ DEFAULT_SECRETS[2], - readU64(bytes, 8) ^ DEFAULT_SEED, - ); - let a = readU64(bytes, length - 16) ^ BigInt(length); - let b = readU64(bytes, length - 8); - - a ^= DEFAULT_SECRETS[1]; - b ^= seed; - - [a, b] = rapidMum(a, b); - - return rapidMix(a ^ 0xaaaaaaaaaaaaaaaan, b ^ DEFAULT_SECRETS[1] ^ BigInt(length)); -} - -/** - * @param {Uint8Array} bytes - */ -function rapidHashV3Hex(bytes) { - return rapidHashV3(bytes).toString(16).padStart(16, "0"); -} - -/** - * @param {Uint8Array} bytes - * @param {number} nibbles - */ -export function rapidHashV3Prefix(bytes, nibbles) { - return rapidHashV3Hex(bytes).slice(0, nibbles); -} diff --git a/website_next/wallets/lookup/index.js b/website_next/wallets/lookup/index.js index 73893bdef..54fe4c38b 100644 --- a/website_next/wallets/lookup/index.js +++ b/website_next/wallets/lookup/index.js @@ -16,6 +16,7 @@ const LOOKUP_CONCURRENCY = 8; /** * @typedef {import("./stats.js").AddressStats} AddressStats + * @typedef {import("./bucket.js").AddrHashPrefixMatches} AddrHashPrefixMatches */ /** @@ -37,8 +38,8 @@ const LOOKUP_CONCURRENCY = 8; /** * @typedef {Object} AddressClient * @property {string} domain - * @property {(address: string, options?: { cache?: boolean }) => Promise} getAddress - * @property {(addrType: AddressType, prefix: string, options?: { cache?: boolean }) => Promise} getAddressHashPrefixMatches + * @property {(address: string, options?: { cache?: boolean }) => Promise} getAddress + * @property {(addrType: AddressType, payload: Uint8Array, nibbles: number, options?: { cache?: boolean }) => Promise} getAddressPayloadHashPrefixMatches */ /** @@ -115,9 +116,7 @@ async function fetchBucketMetadata(client, addresses, cache) { if (!cache.has(address)) { cache.set( address, - client.getAddress(address, { cache: false }).then( - (stats) => /** @type {AddressStats} */ (stats), - ), + client.getAddress(address, { cache: false }), ); } } @@ -132,9 +131,7 @@ async function fetchBucketMetadata(client, addresses, cache) { */ async function fetchDirectWalletAddress(client, generated) { try { - const stats = /** @type {AddressStats} */ ( - await client.getAddress(generated.address, { cache: false }) - ); + const stats = await client.getAddress(generated.address, { cache: false }); const historyAddresses = getAddressTxCount(stats) > 0 ? [generated.address] : []; diff --git a/website_next/wallets/scan/branch.js b/website_next/wallets/scan/branch.js index eb1069f84..43bb7e559 100644 --- a/website_next/wallets/scan/branch.js +++ b/website_next/wallets/scan/branch.js @@ -8,17 +8,10 @@ const MAX_SCANNED_ADDRESSES = 1_000; /** * @typedef {import("../derive/address.js").AddressScript} AddressScript - * @typedef {import("../derive/index.js").AddressType} AddressType + * @typedef {import("../lookup/index.js").AddressClient} AddressClient * @typedef {import("../lookup/index.js").WalletAddress} WalletAddress */ -/** - * @typedef {Object} AddressClient - * @property {string} domain - * @property {(address: string, options?: { cache?: boolean }) => Promise} getAddress - * @property {(addrType: AddressType, prefix: string, options?: { cache?: boolean }) => Promise} getAddressHashPrefixMatches - */ - /** * @typedef {Object} ScanProgress * @property {number} scannedCount diff --git a/website_next/wallets/scan/branches.js b/website_next/wallets/scan/branches.js index 1d5a08a6f..1d573d977 100644 --- a/website_next/wallets/scan/branches.js +++ b/website_next/wallets/scan/branches.js @@ -25,17 +25,10 @@ const descriptorBranches = /** @type {const} */ ([ /** * @typedef {import("../derive/address.js").AddressScript} AddressScript - * @typedef {import("../derive/index.js").AddressType} AddressType + * @typedef {import("../lookup/index.js").AddressClient} AddressClient * @typedef {import("./branch.js").WalletAddress} WalletAddress */ -/** - * @typedef {Object} AddressClient - * @property {string} domain - * @property {(address: string, options?: { cache?: boolean }) => Promise} getAddress - * @property {(addrType: AddressType, prefix: string, options?: { cache?: boolean }) => Promise} getAddressHashPrefixMatches - */ - /** * @typedef {WalletAddress & { * branchId: WalletBranchId, diff --git a/website_next/wallets/scan/index.js b/website_next/wallets/scan/index.js index e9b3f0c84..c5c3296d3 100644 --- a/website_next/wallets/scan/index.js +++ b/website_next/wallets/scan/index.js @@ -5,7 +5,7 @@ import { addressScripts } from "../derive/script.js"; /** * @typedef {import("../derive/address.js").AddressScript} AddressScript - * @typedef {import("../derive/index.js").AddressType} AddressType + * @typedef {import("../lookup/index.js").AddressClient} AddressClient * @typedef {Awaited>["addresses"][number]} WalletAddress * @typedef {Awaited>} ScriptScan */ @@ -18,11 +18,9 @@ import { addressScripts } from "../derive/script.js"; */ /** - * @typedef {Object} WalletScanClient - * @property {string} domain - * @property {(address: string, options?: { cache?: boolean }) => Promise} getAddress - * @property {(addrType: AddressType, prefix: string, options?: { cache?: boolean }) => Promise} getAddressHashPrefixMatches - * @property {(options?: { cache?: boolean }) => Promise} getLivePrice + * @typedef {AddressClient & { + * getLivePrice(options?: { cache?: boolean }): Promise, + * }} WalletScanClient */ /** @@ -135,9 +133,7 @@ export async function scanWalletAddresses({ const addresses = scans.flatMap((scan) => scan.addresses) .sort(compareWalletAddresses); - const btcUsdPrice = /** @type {number} */ ( - await client.getLivePrice({ cache: false }) - ); + const btcUsdPrice = await client.getLivePrice({ cache: false }); return { addresses, diff --git a/website_next/wallets/wallet/history/address.js b/website_next/wallets/wallet/history/address.js index 6a6c91e5d..44d1c8fe2 100644 --- a/website_next/wallets/wallet/history/address.js +++ b/website_next/wallets/wallet/history/address.js @@ -4,20 +4,21 @@ const HISTORY_CONCURRENCY = 4; const MAX_SELECTED_ADDRESS_TXS = 100; const historyByBucketKey = - /** @type {Map>>} */ (new Map()); + /** @type {Map>>} */ (new Map()); /** * @typedef {import("../../scan/index.js").WalletAddress} WalletAddress + * @typedef {import("./transaction.js").ApiTransaction} ApiTransaction */ /** * @typedef {Object} AddressHistoryClient - * @property {(address: string, options?: { cache?: boolean }) => Promise} getAddressTxs + * @property {(address: string, options?: { cache?: boolean }) => Promise} getAddressTxs */ /** * @typedef {Object} AddressHistory - * @property {unknown[]} transactions + * @property {ApiTransaction[]} transactions */ /** @@ -30,16 +31,14 @@ function createBucketKey(addresses) { /** * @param {AddressHistoryClient} client * @param {readonly string[]} addresses - * @returns {Promise>} + * @returns {Promise>} */ async function fetchBucketHistory(client, addresses) { const entries = await mapConcurrent( addresses, HISTORY_CONCURRENCY, async (address) => { - const transactions = /** @type {unknown[]} */ ( - await client.getAddressTxs(address, { cache: false }) - ); + const transactions = await client.getAddressTxs(address, { cache: false }); return /** @type {const} */ ([address, transactions]); }, diff --git a/website_next/wallets/wallet/history/cache.js b/website_next/wallets/wallet/history/cache.js index 69366d31a..2ed32daa3 100644 --- a/website_next/wallets/wallet/history/cache.js +++ b/website_next/wallets/wallet/history/cache.js @@ -3,12 +3,13 @@ import { readWalletTransaction } from "./transaction.js"; /** * @typedef {import("../../scan/index.js").WalletAddress} WalletAddress + * @typedef {import("./transaction.js").ApiTransaction} ApiTransaction * @typedef {import("./transaction.js").WalletTransaction} WalletTransaction */ /** * @typedef {Object} TransactionClient - * @property {(address: string, options?: { cache?: boolean }) => Promise} getAddressTxs + * @property {(address: string, options?: { cache?: boolean }) => Promise} getAddressTxs */ /** diff --git a/website_next/wallets/wallet/history/transaction.js b/website_next/wallets/wallet/history/transaction.js index 733fee1fc..f8321898a 100644 --- a/website_next/wallets/wallet/history/transaction.js +++ b/website_next/wallets/wallet/history/transaction.js @@ -1,5 +1,6 @@ /** * @typedef {import("../../scan/index.js").WalletAddress} WalletAddress + * @typedef {Record} ApiTransaction * * @typedef {Object} WalletTransactionAddress * @property {WalletAddress} walletAddress @@ -186,7 +187,7 @@ function getExternalOutputValue(transaction, walletAddressSet) { } /** - * @param {unknown} transaction + * @param {ApiTransaction} transaction * @param {readonly WalletAddress[]} walletAddresses * @returns {WalletTransaction} */ diff --git a/website_next/wallets/wallet/holdings/cache.js b/website_next/wallets/wallet/holdings/cache.js index 0d8c3ecb9..b7013e144 100644 --- a/website_next/wallets/wallet/holdings/cache.js +++ b/website_next/wallets/wallet/holdings/cache.js @@ -3,20 +3,31 @@ import { mapConcurrent } from "../../concurrent.js"; const UTXO_CONCURRENCY = 4; const utxosByBucketKey = - /** @type {Map>>} */ (new Map()); + /** @type {Map>>} */ (new Map()); /** * @typedef {import("../../scan/index.js").WalletAddress} WalletAddress */ +/** + * @typedef {Object} ApiUtxoStatus + * @property {boolean} confirmed + * + * @typedef {Object} ApiUtxo + * @property {string} txid + * @property {number} vout + * @property {number} value + * @property {ApiUtxoStatus} status + */ + /** * @typedef {Object} UtxoClient - * @property {(address: string, options?: { cache?: boolean }) => Promise} getAddressUtxos + * @property {(address: string, options?: { cache?: boolean }) => Promise} getAddressUtxos */ /** * @typedef {Object} AddressUtxos - * @property {unknown[]} utxos + * @property {ApiUtxo[]} utxos */ /** @@ -39,12 +50,11 @@ function createBucketKey(addresses) { /** * @param {UtxoClient} client * @param {string} address + * @returns {Promise} */ async function fetchAddressUtxos(client, address) { try { - return /** @type {unknown[]} */ ( - await client.getAddressUtxos(address, { cache: false }) - ); + return await client.getAddressUtxos(address, { cache: false }); } catch (error) { if (isNotFound(error)) return []; @@ -55,7 +65,7 @@ async function fetchAddressUtxos(client, address) { /** * @param {UtxoClient} client * @param {readonly string[]} addresses - * @returns {Promise>} + * @returns {Promise>} */ async function fetchBucketUtxos(client, addresses) { const entries = await mapConcurrent(