global: private xpub support part 1

This commit is contained in:
nym21
2026-06-16 23:37:03 +02:00
parent 6f430bdb8c
commit 0c7861071d
70 changed files with 5874 additions and 12510 deletions
+10 -1
View File
@@ -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<String>) -> 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<AddrHashPrefixMatches> {
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).
-279
View File
@@ -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<AddrStats> {
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<Txid>,
limit: usize,
) -> Result<Vec<Transaction>> {
let txindices = self.addr_txindices(addr, after_txid, limit)?;
self.transactions_by_indices(&txindices)
}
pub fn addr_txids(
&self,
addr: Addr,
after_txid: Option<Txid>,
limit: usize,
) -> Result<Vec<Txid>> {
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<Txid>,
limit: usize,
) -> Result<Vec<TxIndex>> {
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<Vec<Utxo>> {
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<u64> {
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<Vec<Transaction>> {
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<Height> {
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<TypeIndex> {
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)
}
}
@@ -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<Height> {
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)
}
}
@@ -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<AddrHashPrefixMatches> {
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<AddrHash>,
}
impl AddrHashPrefix {
const MAX_NIBBLES: usize = u64::BITS as usize / 4;
fn parse(prefix: &str) -> Result<Self> {
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
))
}
}
+20
View File
@@ -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<u64> {
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<Vec<Transaction>> {
let bytes = AddrBytes::from_str(addr)?;
let mempool = self.mempool().ok_or(Error::MempoolNotAvailable)?;
Ok(mempool.addr_txs(&bytes, limit))
}
}
+7
View File
@@ -0,0 +1,7 @@
mod activity;
mod hash_prefix;
mod mempool;
mod resolve;
mod stats;
mod txs;
mod utxos;
+33
View File
@@ -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<TypeIndex> {
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)
}
}
+78
View File
@@ -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<AddrStats> {
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<AddrStats> {
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(),
})
}
}
+70
View File
@@ -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<Txid>,
limit: usize,
) -> Result<Vec<Transaction>> {
let txindices = self.addr_txindices(addr, after_txid, limit)?;
self.transactions_by_indices(&txindices)
}
pub fn addr_txids(
&self,
addr: Addr,
after_txid: Option<Txid>,
limit: usize,
) -> Result<Vec<Txid>> {
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<Txid>,
limit: usize,
) -> Result<Vec<TxIndex>> {
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())
}
}
}
+64
View File
@@ -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<Vec<Utxo>> {
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)
}
}
+27 -2
View File
@@ -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<AppState> {
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<AddrHashPrefixParam>,
_: Empty,
State(state): State<AppState>
| {
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::<AddrHashPrefixMatches>()
.not_modified()
.bad_request()
.server_error()
),
)
.api_route(
"/api/address/{address}",
get_with(async |
uri: Uri,
@@ -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,
}
+2
View File
@@ -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::*;
+6
View File
@@ -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 {
@@ -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<Addr>,
}
+2
View File
@@ -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::*;
+29
View File
@@ -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<AddrHashPrefixMatches>}
*/
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
*
@@ -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.
+1
View File
@@ -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() {
+9
View File
@@ -115,6 +115,15 @@
<link rel="stylesheet" href="/learn/charts/stacked/style.css" />
<link rel="stylesheet" href="/learn/contents/style.css" />
<link rel="stylesheet" href="/build/style.css" />
<link rel="stylesheet" href="/wallets/style.css" />
<link rel="stylesheet" href="/wallets/forms.css" />
<link rel="stylesheet" href="/wallets/settings.css" />
<link rel="stylesheet" href="/wallets/selector.css" />
<link rel="stylesheet" href="/wallets/summary.css" />
<link rel="stylesheet" href="/wallets/receive.css" />
<link rel="stylesheet" href="/wallets/table.css" />
<link rel="stylesheet" href="/wallets/address.css" />
<link rel="stylesheet" href="/wallets/history.css" />
<!-- /IMPORTMAP -->
<script>
+1
View File
@@ -0,0 +1 @@
../modules
File diff suppressed because it is too large Load Diff
+2
View File
@@ -2,6 +2,7 @@ import { createBuildPage } from "./build/index.js";
import { createExplorePage } from "./explore/index.js";
import { createHomePage } from "./home/index.js";
import { createLearnPage } from "./learn/index.js";
import { createWalletsPage } from "./wallets/index.js";
/** @type {Record<string, () => HTMLElement>} */
const routes = {
@@ -9,6 +10,7 @@ const routes = {
"/explore": createExplorePage,
"/learn": createLearnPage,
"/build": createBuildPage,
"/wallets": createWalletsPage,
};
/** @param {string} pathname */
+1 -1
View File
@@ -11,5 +11,5 @@
"lib": ["DOM", "DOM.Iterable", "ESNext", "WebWorker"],
"skipLibCheck": true,
},
"exclude": ["assets", "./scripts/modules"],
"exclude": ["assets", "modules", "./scripts/modules"],
}
+1 -1
View File
@@ -1,3 +1,3 @@
import { BrkClient } from "../modules/brk-client/index.js";
export const brk = new BrkClient("https://bitview.space");
export const brk = new BrkClient("http://localhost:3110");
+38
View File
@@ -0,0 +1,38 @@
export const addressScripts = /** @type {const} */ ([
{ id: "v0_p2wpkh", label: "P2WPKH" },
{ id: "v1_p2tr", label: "P2TR" },
{ id: "p2sh_p2wpkh", label: "Nested P2WPKH" },
{ id: "p2pkh", label: "P2PKH" },
]);
/**
* @typedef {typeof addressScripts[number]["id"]} AddressScript
*/
/**
* @param {AddressScript} [value]
*/
export function createAddressScriptSelect(value) {
const select = document.createElement("select");
select.name = "script";
for (const { id, label } of addressScripts) {
const option = document.createElement("option");
option.value = id;
option.selected = id === value;
option.append(label);
select.append(option);
}
return select;
}
/**
* @param {HTMLSelectElement} select
* @returns {AddressScript}
*/
export function readAddressScript(select) {
return /** @type {AddressScript} */ (select.value);
}
+85
View File
@@ -0,0 +1,85 @@
import { createElement } from "./dom.js";
import { formatNumber } from "./format.js";
import {
arePrivateValuesHidden,
createPrivateText,
} from "./privacy-view.js";
/**
* @typedef {Object} WalletAddress
* @property {number} index
* @property {string} address
* @property {string} [branchLabel]
* @property {number} historyBucketSize
*/
/**
* @param {string} text
*/
export function createGroupedAddress(text) {
const element = createElement("code", "wallets__address");
const groups = text.match(/.{1,4}/g) ?? [];
for (let groupIndex = 0; groupIndex < groups.length; groupIndex += 1) {
const group = createElement("span", "wallets__address-group");
for (const character of groups[groupIndex]) {
const span = createElement(
"span",
Number.isNaN(Number(character))
? "wallets__address-letter"
: "wallets__address-number",
);
span.append(character);
group.append(span);
}
element.append(group);
if (groupIndex < groups.length - 1) {
element.append(" ");
}
}
return element;
}
/**
* @param {string} address
*/
export function createPrivateAddress(address) {
const hidden = createPrivateText(address);
const element = arePrivateValuesHidden()
? createGroupedAddress(hidden)
: createGroupedAddress(address);
element.setAttribute("data-wallets-private-address", address);
return element;
}
/**
* @param {WalletAddress} row
*/
function createAddressBadge(row) {
const badge = createElement("span", "wallets__address-badge");
const label = row.branchLabel?.toLowerCase() ?? "address";
badge.setAttribute("data-wallets-address-branch", label);
badge.append(label, ` #${formatNumber(row.index)}`);
return badge;
}
/**
* @param {WalletAddress} row
*/
export function createAddressCellContent(row) {
const element = createElement("div", "wallets__address-cell");
const anonSet = createElement("span", "wallets__address-meta");
anonSet.append(`anon set: ${formatNumber(row.historyBucketSize)}`);
element.append(createAddressBadge(row), createPrivateAddress(row.address), anonSet);
return element;
}
+43
View File
@@ -0,0 +1,43 @@
main.wallets {
.wallets__address-cell {
display: grid;
gap: 0.25rem;
}
.wallets__address-meta {
color: var(--gray);
font-size: var(--font-size-xs);
line-height: var(--line-height-xs);
}
.wallets__address-badge {
display: inline-flex;
align-items: center;
justify-self: start;
min-height: 1rem;
border: 1px solid color-mix(in oklch, var(--gray) 28%, transparent);
border-radius: 0.25rem;
padding: 0 0.25rem;
color: color-mix(in oklch, var(--white) 76%, var(--gray));
line-height: 1;
}
.wallets__address {
display: flex;
flex-wrap: wrap;
gap: 0 0.375rem;
max-width: 40rem;
}
.wallets__address-group {
white-space: nowrap;
}
.wallets__address-letter {
color: var(--white);
}
.wallets__address-number {
color: color-mix(in oklch, var(--white) 50%, var(--gray));
}
}
+20
View File
@@ -0,0 +1,20 @@
import { createAddressTable } from "./table-view.js";
/**
* @typedef {Parameters<typeof createAddressTable>[0][number] & {
* branchLabel?: string,
* }} WalletAddress
*/
/**
* @typedef {Parameters<typeof createAddressTable>[1]} AddressTableOptions
*/
/**
* @param {HTMLElement} results
* @param {WalletAddress[]} addresses
* @param {AddressTableOptions} tableOptions
*/
export function renderWalletAddresses(results, addresses, tableOptions) {
results.replaceChildren(createAddressTable(addresses, tableOptions));
}
+24
View File
@@ -0,0 +1,24 @@
/**
* @template Item, Result
* @param {readonly Item[]} items
* @param {number} limit
* @param {(item: Item) => Promise<Result>} fn
* @returns {Promise<Result[]>}
*/
export async function mapConcurrent(items, limit, fn) {
const results = /** @type {Result[]} */ ([]);
let next = 0;
const workerCount = Math.min(limit, items.length);
const workers = Array.from({ length: workerCount }, async () => {
while (next < items.length) {
const index = next;
next += 1;
results[index] = await fn(items[index]);
}
});
await Promise.all(workers);
return results;
}
+146
View File
@@ -0,0 +1,146 @@
import { createElement } from "./dom.js";
/**
* @typedef {Object} EmptyWalletViewOptions
* @property {() => void} onAdd
*/
/**
* @typedef {Object} LockedWalletViewOptions
* @property {(password: string, button: HTMLButtonElement, status: HTMLElement) => void | Promise<void>} onUnlock
* @property {() => void} onReset
*/
/**
* @typedef {Object} SetupWalletViewOptions
* @property {(password: string, button: HTMLButtonElement, status: HTMLElement) => void | Promise<void>} onCreate
*/
/**
* @typedef {Object} UnlockedWalletView
* @property {HTMLElement} settings
* @property {HTMLElement} summary
* @property {HTMLElement} status
* @property {HTMLElement} results
* @property {HTMLElement[]} nodes
*/
/**
* @param {EmptyWalletViewOptions} options
*/
export function createEmptyWalletView(options) {
const empty = createElement("section", "wallets__empty");
const text = document.createElement("p");
const button = document.createElement("button");
text.append("No watch-only wallets yet");
button.type = "button";
button.append("Add watch-only wallet");
button.addEventListener("click", options.onAdd);
empty.append(text, button);
return empty;
}
/**
* @param {SetupWalletViewOptions} options
*/
export function createSetupWalletView(options) {
const section = createElement("section", "wallets__setup");
const title = document.createElement("h1");
const description = createElement("div", "wallets__setup-description");
const form = createElement("form", "wallets__setup-form");
const password = document.createElement("input");
const button = document.createElement("button");
const status = createElement("p", "wallets__status");
title.append("Wallets");
description.append(
createDescriptionText("Import an extended public key, often called an xpub, or a watch-only descriptor to view a Bitcoin wallet without giving this site spending access."),
createDescriptionText("Your wallet sources stay in this browser and are encrypted before they are saved. Set a password for this local wallet vault."),
createDescriptionText("If you forget the password, you can reset the vault and import the xpubs or descriptors again."),
);
password.name = "password";
password.type = "password";
password.autocomplete = "new-password";
password.placeholder = "Set password";
password.required = true;
button.type = "submit";
button.append("Continue");
status.setAttribute("role", "status");
form.append(password, button);
form.addEventListener("submit", (event) => {
event.preventDefault();
void options.onCreate(password.value, button, status);
});
section.append(title, description, form, status);
return section;
}
/**
* @param {LockedWalletViewOptions} options
*/
export function createLockedWalletView(options) {
const section = createElement("section", "wallets__unlock");
const form = createElement("form", "wallets__unlock-form");
const password = document.createElement("input");
const button = document.createElement("button");
const reset = document.createElement("button");
const status = createElement("p", "wallets__status");
password.name = "password";
password.type = "password";
password.autocomplete = "current-password";
password.placeholder = "Password";
password.required = true;
button.type = "submit";
button.append("Unlock");
reset.type = "button";
reset.className = "wallets__reset";
reset.append("Reset vault");
status.setAttribute("role", "status");
form.append(password, button);
form.addEventListener("submit", (event) => {
event.preventDefault();
void options.onUnlock(password.value, button, status);
});
reset.addEventListener("click", options.onReset);
section.append(form, reset, status);
return section;
}
/**
* @param {string} text
*/
function createDescriptionText(text) {
const paragraph = document.createElement("p");
paragraph.append(text);
return paragraph;
}
/**
* @returns {UnlockedWalletView}
*/
export function createUnlockedWalletView() {
const settings = createElement("section", "wallets__settings");
const summary = createElement("section", "wallets__summary");
const status = createElement("p", "wallets__status");
const results = createElement("section", "wallets__results");
settings.setAttribute("aria-label", "Wallet settings");
status.setAttribute("role", "status");
summary.setAttribute("aria-label", "Wallets summary");
results.setAttribute("aria-label", "Wallets results");
return {
settings,
summary,
status,
results,
nodes: [settings, summary, status, results],
};
}
+46
View File
@@ -0,0 +1,46 @@
/**
* @template {keyof HTMLElementTagNameMap} Tag
* @param {Tag} tag
* @param {string} className
*/
export function createElement(tag, className) {
const element = document.createElement(tag);
element.className = className;
return element;
}
/**
* @param {string} label
* @param {HTMLInputElement | HTMLSelectElement} control
*/
export function createField(label, control) {
const element = createElement("label", "wallets__field");
const text = createElement("span", "wallets__label");
text.append(label);
element.append(text, control);
return element;
}
/**
* @param {HTMLButtonElement} button
* @param {boolean} busy
* @param {string} idleLabel
* @param {string} busyLabel
*/
export function setBusy(button, busy, idleLabel, busyLabel) {
button.disabled = busy;
button.ariaBusy = busy ? "true" : "false";
button.textContent = busy ? busyLabel : idleLabel;
}
/**
* @param {HTMLElement} status
* @param {string} text
*/
export function setStatus(status, text) {
status.textContent = text;
}
+6
View File
@@ -0,0 +1,6 @@
/**
* @param {unknown} error
*/
export function getErrorMessage(error) {
return error instanceof Error ? error.message : "Request failed";
}
+26
View File
@@ -0,0 +1,26 @@
/**
* @param {number} value
*/
export function formatNumber(value) {
return new Intl.NumberFormat("en-US").format(value);
}
/**
* @param {number} sats
*/
export function formatBtc(sats) {
return `${(sats / 100_000_000).toLocaleString("en-US", {
maximumFractionDigits: 8,
})} BTC`;
}
/**
* @param {number} dollars
*/
export function formatUsd(dollars) {
return new Intl.NumberFormat("en-US", {
currency: "USD",
maximumFractionDigits: 0,
style: "currency",
}).format(dollars);
}
+168
View File
@@ -0,0 +1,168 @@
main.wallets {
.wallets__empty,
.wallets__setup,
.wallets__unlock {
display: grid;
gap: 1rem;
place-content: center;
min-height: 16rem;
text-align: center;
h2,
p {
margin: 0;
}
}
.wallets__setup {
max-width: 36rem;
margin-inline: auto;
h1 {
margin: 0;
font-family: var(--font-serif);
font-size: clamp(3rem, 8vw, 5.5rem);
font-weight: 400;
line-height: 0.9;
}
}
.wallets__setup-description {
display: grid;
gap: 0.625rem;
color: var(--gray);
font-size: var(--font-size-md);
line-height: var(--line-height-md);
}
.wallets__unlock-form,
.wallets__setup-form,
.wallets__dialog-form {
display: grid;
gap: 0.75rem;
}
.wallets__unlock-form,
.wallets__setup-form {
grid-template-columns: minmax(12rem, 18rem) auto;
align-items: end;
justify-content: center;
}
.wallets__dialog {
width: min(100% - 2rem, 30rem);
border: 1px solid color-mix(in oklch, var(--gray) 36%, transparent);
border-radius: 0.5rem;
padding: 1rem;
color: var(--white);
background: var(--black);
h2 {
margin: 0;
}
}
.wallets__dialog::backdrop {
background: color-mix(in oklch, var(--black) 72%, transparent);
}
.wallets__dialog-actions {
display: flex;
gap: 0.5rem;
justify-content: end;
}
.wallets__field {
display: grid;
gap: 0.375rem;
min-width: 0;
}
.wallets__label {
color: var(--gray);
font-size: var(--font-size-xs);
line-height: var(--line-height-xs);
text-transform: uppercase;
}
.wallets__field :is(input, select),
.wallets__setup-form input,
.wallets__unlock-form input,
.wallets__actions button,
.wallets__empty button,
.wallets__setup-form button,
.wallets__unlock-form button,
.wallets__reset,
.wallets__dialog-form button {
min-width: 0;
height: var(--control-height);
border: 1px solid color-mix(in oklch, var(--gray) 45%, transparent);
border-radius: 0.375rem;
padding: 0 0.875rem;
color: var(--white);
background: color-mix(in oklch, var(--black) 72%, var(--white));
font: inherit;
line-height: 1;
}
.wallets__field :is(input, select):focus-visible,
.wallets__setup-form input:focus-visible,
.wallets__unlock-form input:focus-visible,
.wallets__actions button:focus-visible,
.wallets__empty button:focus-visible,
.wallets__setup-form button:focus-visible,
.wallets__unlock-form button:focus-visible,
.wallets__reset:focus-visible,
.wallets__dialog-form button:focus-visible {
outline: 2px solid var(--orange);
outline-offset: 2px;
}
.wallets__field input::placeholder,
.wallets__setup-form input::placeholder {
color: color-mix(in oklch, var(--gray) 70%, transparent);
}
.wallets__actions button,
.wallets__empty button,
.wallets__setup-form button,
.wallets__unlock-form button,
.wallets__dialog-form button[type="submit"] {
border-color: var(--orange);
color: var(--black);
background: var(--orange);
cursor: pointer;
}
.wallets__dialog-form button[type="button"] {
cursor: pointer;
}
.wallets__reset {
justify-self: center;
color: var(--gray);
background: transparent;
cursor: pointer;
}
.wallets__actions button:disabled,
.wallets__empty button:disabled,
.wallets__setup-form button:disabled,
.wallets__unlock-form button:disabled,
.wallets__reset:disabled,
.wallets__dialog-form button:disabled {
border-color: var(--gray);
color: var(--black);
background: var(--gray);
cursor: progress;
}
}
@media (max-width: 34rem) {
main.wallets {
.wallets__unlock-form,
.wallets__setup-form {
grid-template-columns: 1fr;
}
}
}
+251
View File
@@ -0,0 +1,251 @@
import { createElement } from "./dom.js";
import { formatBtc } from "./format.js";
import {
createPrivateValue,
setPrivateTitle,
setPrivateValue,
} from "./privacy-view.js";
/**
* @typedef {Object} AddressHistory
* @property {unknown[]} transactions
*/
/**
* @param {unknown} transaction
*/
function getTransactionId(transaction) {
if (
transaction &&
typeof transaction === "object" &&
"txid" in transaction &&
typeof transaction.txid === "string"
) {
return transaction.txid;
}
return "";
}
/**
* @param {string} txid
*/
function formatTxid(txid) {
return txid.length > 16 ? `${txid.slice(0, 8)}...${txid.slice(-8)}` : txid;
}
/**
* @param {unknown} value
*/
function readSats(value) {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
}
/**
* @param {unknown} output
* @param {string} address
*/
function isAddressOutput(output, address) {
return (
output &&
typeof output === "object" &&
"scriptpubkeyAddress" in output &&
output.scriptpubkeyAddress === address
);
}
/**
* @param {unknown} output
*/
function getOutputValue(output) {
if (
output &&
typeof output === "object" &&
"value" in output
) {
return readSats(output.value);
}
return 0;
}
/**
* @param {unknown} transaction
* @param {string} address
*/
function getTransactionReceived(transaction, address) {
if (
!transaction ||
typeof transaction !== "object" ||
!("vout" in transaction) ||
!Array.isArray(transaction.vout)
) {
return 0;
}
return transaction.vout.reduce((total, output) => {
return (
total + (isAddressOutput(output, address) ? getOutputValue(output) : 0)
);
}, 0);
}
/**
* @param {unknown} transaction
* @param {string} address
*/
function getTransactionSent(transaction, address) {
if (
!transaction ||
typeof transaction !== "object" ||
!("vin" in transaction) ||
!Array.isArray(transaction.vin)
) {
return 0;
}
return transaction.vin.reduce((total, input) => {
if (
!input ||
typeof input !== "object" ||
!("prevout" in input)
) {
return total;
}
const prevout = input.prevout;
return (
total + (isAddressOutput(prevout, address) ? getOutputValue(prevout) : 0)
);
}, 0);
}
/**
* @param {unknown} transaction
*/
function getTransactionFee(transaction) {
if (
transaction &&
typeof transaction === "object" &&
"fee" in transaction
) {
return readSats(transaction.fee);
}
return 0;
}
/**
* @param {number} net
*/
function getTransactionDirection(net) {
if (net > 0) return "received";
if (net < 0) return "sent";
return "moved";
}
/**
* @param {unknown} transaction
*/
function getTransactionTime(transaction) {
if (
transaction &&
typeof transaction === "object" &&
"status" in transaction &&
transaction.status &&
typeof transaction.status === "object" &&
"blockTime" in transaction.status &&
typeof transaction.status.blockTime === "number"
) {
return new Date(transaction.status.blockTime * 1_000).toLocaleDateString(
"en-US",
);
}
return "mempool";
}
/**
* @param {AddressHistory} history
* @param {string} address
*/
export function createHistoryContent(history, address) {
const element = createElement("div", "wallets__history");
const list = createElement("ol", "wallets__history-list");
for (const transaction of history.transactions) {
const item = document.createElement("li");
const txid = document.createElement("code");
const date = document.createElement("span");
const direction = document.createElement("span");
const amount = document.createElement("strong");
const fee = document.createElement("span");
const received = getTransactionReceived(transaction, address);
const sent = getTransactionSent(transaction, address);
const net = received - sent;
const id = getTransactionId(transaction);
setPrivateTitle(txid, id);
setPrivateValue(txid, formatTxid(id));
date.append(getTransactionTime(transaction));
direction.append(getTransactionDirection(net));
setPrivateValue(amount, formatBtc(Math.abs(net)), "fixed");
fee.append(
"fee ",
createPrivateValue("span", formatBtc(getTransactionFee(transaction)), "fixed"),
);
item.append(date, direction, amount, fee, txid);
list.append(item);
}
element.append(list);
return element;
}
/**
* @param {string} text
*/
export function createHistoryMessage(text) {
const element = createElement("p", "wallets__history-message");
element.append(text);
return element;
}
/**
* @param {Node} content
* @param {number} columnCount
*/
function createHistoryCell(content, columnCount) {
const cell = document.createElement("td");
cell.colSpan = columnCount;
cell.append(content);
return cell;
}
/**
* @param {Node} content
* @param {number} columnCount
*/
export function createHistoryRow(content, columnCount) {
const row = document.createElement("tr");
row.append(createHistoryCell(content, columnCount));
return row;
}
/**
* @param {HTMLTableRowElement} row
* @param {Node} content
* @param {number} columnCount
*/
export function replaceHistoryRowContent(row, content, columnCount) {
row.replaceChildren(createHistoryCell(content, columnCount));
}
+63
View File
@@ -0,0 +1,63 @@
main.wallets {
.wallets__history-button {
height: 2rem;
border: 1px solid color-mix(in oklch, var(--gray) 36%, transparent);
border-radius: 0.375rem;
padding: 0 0.625rem;
color: var(--white);
background: transparent;
font: inherit;
cursor: pointer;
}
.wallets__history-button:disabled {
color: var(--gray);
cursor: progress;
}
.wallets__history-button:focus-visible {
outline: 2px solid var(--orange);
outline-offset: 2px;
}
.wallets__history {
display: grid;
gap: 0.75rem;
padding: 0.25rem 0 0.75rem;
}
.wallets__history-message {
margin: 0;
color: var(--gray);
}
.wallets__history-list {
display: grid;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
li {
display: grid;
grid-template-columns:
minmax(5.5rem, 0.7fr)
minmax(4.75rem, 0.6fr)
minmax(8rem, 0.8fr)
minmax(7.5rem, 0.8fr)
minmax(10rem, 1fr);
gap: 0.75rem 1rem;
align-items: baseline;
}
strong {
color: var(--white);
white-space: nowrap;
}
span {
color: var(--gray);
white-space: nowrap;
}
}
}
+83
View File
@@ -0,0 +1,83 @@
import {
createElement,
createField,
} from "./dom.js";
import { arePrivateValuesHidden } from "./privacy-view.js";
/**
* @typedef {Object} AddWalletFormSubmit
* @property {HTMLInputElement} name
* @property {HTMLInputElement} xpub
* @property {HTMLButtonElement} submit
* @property {HTMLElement} status
* @property {HTMLFormElement} form
*/
/**
* @typedef {Object} AddWalletFormOptions
* @property {() => void} onCancel
* @property {(submit: AddWalletFormSubmit) => void | Promise<void>} onSubmit
*/
function createXpubInput() {
const input = document.createElement("input");
input.name = "xpub";
input.type = arePrivateValuesHidden() ? "password" : "text";
input.setAttribute("data-wallets-private-input", "");
input.autocomplete = "off";
input.placeholder = "xpub or descriptor...";
input.required = true;
input.spellcheck = false;
return input;
}
/**
* @param {AddWalletFormOptions} options
*/
export function createAddWalletForm(options) {
const form = createElement("form", "wallets__dialog-form");
const title = document.createElement("h2");
const name = document.createElement("input");
const xpub = createXpubInput();
const actions = createElement("div", "wallets__dialog-actions");
const cancel = document.createElement("button");
const submit = document.createElement("button");
const status = createElement("p", "wallets__status");
const fields = [
createField("name", name),
createField("xpub or descriptor", xpub),
];
title.append("Watch wallet");
name.name = "name";
name.autocomplete = "off";
name.placeholder = "Wallet name";
name.required = true;
cancel.type = "button";
cancel.append("Cancel");
submit.type = "submit";
submit.append("Add");
status.setAttribute("role", "status");
actions.append(cancel, submit);
form.append(
title,
...fields,
actions,
status,
);
cancel.addEventListener("click", options.onCancel);
form.addEventListener("submit", (event) => {
event.preventDefault();
void options.onSubmit({
name,
xpub,
submit,
status,
form,
});
});
return form;
}
+427
View File
@@ -0,0 +1,427 @@
import { brk } from "../utils/client.js";
import { createGroupedAddress } from "./address-view.js";
import { renderWalletAddresses } from "./addresses-view.js";
import {
createEmptyWalletView,
createLockedWalletView,
createSetupWalletView,
createUnlockedWalletView,
} from "./content-view.js";
import {
createElement,
setBusy,
setStatus,
} from "./dom.js";
import { getErrorMessage } from "./errors.js";
import {
createAddWalletForm,
} from "./import-view.js";
import {
syncPrivacyButton,
togglePrivateValues,
} from "./privacy-view.js";
import { fetchAddressHistory } from "./privacy/address-history.js";
import { renderReceiveButton } from "./receive-view.js";
import { inferAddressScript } from "./script-inference.js";
import { readWalletSourceText } from "./wallet-source.js";
import {
addWallet,
createWalletVault,
hasStoredWallets,
loadWallets,
resetWalletVault,
updateWalletScript,
} from "./storage/wallets.js";
import {
createScanPendingMessage,
scanWalletAddresses,
} from "./scan.js";
import {
initWalletSelector,
renderWalletSelector,
} from "./selector-view.js";
import { renderWalletSettings } from "./settings-view.js";
import { renderWalletSummary } from "./summary-view.js";
/**
* @typedef {import("./xpub/address.js").AddressScript} AddressScript
* @typedef {import("./scan.js").WalletAddress} WalletAddress
* @typedef {import("./scan.js").WalletScan} WalletScan
* @typedef {import("./storage/wallets.js").StoredWallet} StoredWallet
*/
/**
* @param {WalletScan} scan
* @param {HTMLElement} summary
* @param {HTMLElement} settings
* @param {HTMLElement} results
*/
function renderWalletScan(scan, summary, settings, results) {
renderWalletSummary(summary, scan.addresses, scan.btcUsdPrice);
renderReceiveButton(settings, scan.receiveAddress);
renderWalletAddresses(results, scan.addresses, {
fetchHistory(address) {
return fetchAddressHistory(brk, address);
},
getErrorMessage,
});
}
export function createWalletsPage() {
const main = createElement("main", "wallets");
const header = createElement("header", "wallets__header");
const actions = createElement("div", "wallets__actions");
const privacyButton = document.createElement("button");
const lockButton = document.createElement("button");
const selector = createElement("section", "wallets__selector");
const walletList = createElement("div", "wallets__wallet-list");
const addButton = document.createElement("button");
const content = createElement("section", "wallets__content");
const addDialog = createElement("dialog", "wallets__dialog");
/** @type {StoredWallet[]} */
let wallets = [];
let selectedWalletId = "";
let pageLocked = hasStoredWallets();
let pagePassword = "";
/** @type {Map<string, { xpub: string, scan?: WalletScan, scanPromise?: Promise<WalletScan | undefined> }>} */
const walletStates = new Map();
privacyButton.type = "button";
syncPrivacyButton(privacyButton);
lockButton.type = "button";
lockButton.append("Lock");
addButton.type = "button";
addButton.append("Add watch-only wallet");
content.setAttribute("aria-live", "polite");
walletList.setAttribute("tabindex", "0");
walletList.setAttribute("aria-label", "Wallets");
header.append(selector, actions);
actions.append(addButton, privacyButton, lockButton);
selector.append(walletList);
initWalletSelector(walletList, {
getSelectedWalletId() {
return selectedWalletId;
},
onSelect: selectWallet,
});
/**
* @returns {StoredWallet | undefined}
*/
function getSelectedWallet() {
return wallets.find((wallet) => wallet.id === selectedWalletId);
}
/**
* @param {string} walletId
*/
function selectWallet(walletId) {
selectedWalletId = walletId;
render();
}
function lockPage() {
wallets = [];
selectedWalletId = "";
walletStates.clear();
pagePassword = "";
pageLocked = hasStoredWallets();
render();
}
function resetWallets() {
resetWalletVault();
wallets = [];
selectedWalletId = "";
walletStates.clear();
pagePassword = "";
pageLocked = false;
render();
}
function openAddDialog() {
addDialog.replaceChildren(createAddWalletForm({
onCancel() {
addDialog.close();
},
onSubmit(submit) {
return addStoredWallet(submit);
},
}));
addDialog.showModal();
}
privacyButton.addEventListener("click", () => {
togglePrivateValues(main, privacyButton, createGroupedAddress);
});
lockButton.addEventListener("click", () => {
lockPage();
});
addButton.addEventListener("click", () => {
openAddDialog();
});
function syncSelectedWallet() {
selectedWalletId = wallets.some((wallet) => wallet.id === selectedWalletId)
? selectedWalletId
: wallets[0]?.id ?? "";
}
function renderLockedWallet() {
content.replaceChildren(createLockedWalletView({
onUnlock(password, button, status) {
return unlockWallet(password, button, status);
},
onReset() {
resetWallets();
},
}));
}
function renderSetupWallet() {
content.replaceChildren(createSetupWalletView({
onCreate(password, button, status) {
return setupWallet(password, button, status);
},
}));
}
/**
* @param {StoredWallet} wallet
* @param {{ xpub: string, scan?: WalletScan, scanPromise?: Promise<WalletScan | undefined> }} state
*/
function renderUnlockedWallet(wallet, state) {
const view = createUnlockedWalletView();
content.replaceChildren(...view.nodes);
renderWalletSettings(view.settings, wallet, {
onScriptChange(script, select, status) {
return updateSelectedWalletScript(wallet, state, script, select, status);
},
});
if (state.scan) {
renderWalletScan(state.scan, view.summary, view.settings, view.results);
setStatus(view.status, "Ready");
return;
}
if (!state.scanPromise) {
state.scanPromise = scanWalletAddresses({
client: brk,
xpub: state.xpub,
start: 0,
script: wallet.script,
status: view.status,
});
} else {
setStatus(view.status, createScanPendingMessage());
}
void state.scanPromise.then((scan) => {
if (!scan || walletStates.get(wallet.id) !== state) return;
state.scan = scan;
if (pageLocked || selectedWalletId !== wallet.id || !view.results.isConnected) {
return;
}
renderWalletScan(scan, view.summary, view.settings, view.results);
setStatus(view.status, "Ready");
});
}
/**
* @param {string} password
*/
async function unlockPageWallets(password) {
wallets = await loadWallets(password);
selectedWalletId = wallets.some((wallet) => wallet.id === selectedWalletId)
? selectedWalletId
: wallets[0]?.id ?? "";
walletStates.clear();
pagePassword = password;
for (const wallet of wallets) {
walletStates.set(wallet.id, { xpub: wallet.xpub });
}
}
/**
* @param {string} password
* @param {HTMLButtonElement} button
* @param {HTMLElement} status
*/
async function unlockWallet(password, button, status) {
setBusy(button, true, "Unlock", "Unlocking");
setStatus(status, "");
try {
await unlockPageWallets(password);
pageLocked = false;
render();
} catch {
setStatus(status, "Invalid password");
} finally {
setBusy(button, false, "Unlock", "Unlocking");
}
}
/**
* @param {string} password
* @param {HTMLButtonElement} button
* @param {HTMLElement} status
*/
async function setupWallet(password, button, status) {
setBusy(button, true, "Continue", "Creating");
setStatus(status, "");
try {
await createWalletVault(password);
wallets = [];
selectedWalletId = "";
walletStates.clear();
pagePassword = password;
pageLocked = false;
render();
} catch (error) {
setStatus(status, getErrorMessage(error));
} finally {
setBusy(button, false, "Continue", "Creating");
}
}
/**
* @param {StoredWallet} wallet
* @param {{ xpub: string, scan?: WalletScan, scanPromise?: Promise<WalletScan | undefined> }} state
* @param {AddressScript} script
* @param {HTMLSelectElement} select
* @param {HTMLElement} status
*/
async function updateSelectedWalletScript(wallet, state, script, select, status) {
if (script === wallet.script) return;
select.disabled = true;
setStatus(status, "Saving");
try {
wallets = await updateWalletScript(wallets, {
walletId: wallet.id,
script,
}, pagePassword);
walletStates.set(wallet.id, { xpub: state.xpub });
render();
} catch (error) {
select.value = wallet.script;
setStatus(status, getErrorMessage(error));
} finally {
select.disabled = false;
}
}
function renderSelectedWallet() {
const hasVault = hasStoredWallets();
const setup = !hasVault && !pagePassword;
const locked = pageLocked && hasVault;
const wallet = getSelectedWallet();
const state = wallet ? walletStates.get(wallet.id) : undefined;
main.toggleAttribute("data-wallets-page-locked", locked || setup);
header.hidden = locked || setup;
selector.hidden = locked || setup;
lockButton.hidden = locked || setup || !pagePassword;
if (setup) {
renderSetupWallet();
return;
}
if (locked) {
renderLockedWallet();
return;
}
if (!wallet) {
content.replaceChildren(createEmptyWalletView({
onAdd() {
openAddDialog();
},
}));
return;
}
if (state) {
renderUnlockedWallet(wallet, state);
return;
}
renderLockedWallet();
}
function render() {
syncSelectedWallet();
if (pageLocked && hasStoredWallets()) {
walletList.replaceChildren();
} else {
renderWalletSelector(walletList, {
wallets,
selectedWalletId,
onSelect: selectWallet,
});
}
renderSelectedWallet();
}
/**
* @param {Object} options
* @param {HTMLInputElement} options.name
* @param {HTMLInputElement} options.xpub
* @param {HTMLButtonElement} options.submit
* @param {HTMLElement} options.status
* @param {HTMLFormElement} options.form
*/
async function addStoredWallet({
name,
xpub,
submit,
status,
form,
}) {
setBusy(submit, true, "Add", "Adding");
setStatus(status, "Checking address type");
try {
const value = readWalletSourceText(xpub.value);
const script = await inferAddressScript(brk, value);
setStatus(status, "Saving");
const added = await addWallet(wallets, {
name: name.value,
xpub: value,
script,
}, pagePassword);
form.reset();
addDialog.close();
wallets = added.wallets;
selectedWalletId = added.wallet.id;
pageLocked = false;
walletStates.set(added.wallet.id, { xpub: added.wallet.xpub });
render();
} catch (error) {
setStatus(status, getErrorMessage(error));
} finally {
setBusy(submit, false, "Add", "Adding");
}
}
main.append(header, selector, content, addDialog);
render();
return main;
}
+119
View File
@@ -0,0 +1,119 @@
const FIXED_PRIVATE_TEXT = "*****";
let privateValuesHidden = false;
export function arePrivateValuesHidden() {
return privateValuesHidden;
}
/**
* @param {string} value
*/
export function createPrivateText(value) {
return [...value].map((character) => {
return character === " " ? " " : "*";
}).join("");
}
/**
* @param {string} value
* @param {string | null} mode
*/
function maskPrivateText(value, mode) {
return mode === "fixed" ? FIXED_PRIVATE_TEXT : createPrivateText(value);
}
/**
* @param {HTMLElement} element
* @param {string} value
* @param {"exact" | "fixed"} [mode]
*/
export function setPrivateValue(element, value, mode = "exact") {
element.setAttribute("data-wallets-private-value", value);
element.setAttribute("data-wallets-private-mode", mode);
element.textContent = privateValuesHidden ? maskPrivateText(value, mode) : value;
}
/**
* @param {HTMLElement} element
* @param {string} value
*/
export function setPrivateTitle(element, value) {
element.setAttribute("data-wallets-private-title", value);
element.title = privateValuesHidden ? createPrivateText(value) : value;
}
/**
* @template {keyof HTMLElementTagNameMap} Tag
* @param {Tag} tag
* @param {string} value
* @param {"exact" | "fixed"} [mode]
*/
export function createPrivateValue(tag, value, mode = "exact") {
const element = document.createElement(tag);
setPrivateValue(element, value, mode);
return element;
}
/**
* @param {HTMLElement} root
* @param {(text: string) => HTMLElement} createAddress
*/
export function syncPrivateValues(root, createAddress) {
const values = root.querySelectorAll("[data-wallets-private-value]");
const titles = root.querySelectorAll("[data-wallets-private-title]");
const addresses = root.querySelectorAll("[data-wallets-private-address]");
const inputs = root.querySelectorAll("[data-wallets-private-input]");
for (const value of values) {
const text = value.getAttribute("data-wallets-private-value") ?? "";
const mode = value.getAttribute("data-wallets-private-mode");
value.textContent = privateValuesHidden
? maskPrivateText(text, mode)
: text;
}
for (const element of titles) {
const title = /** @type {HTMLElement} */ (element);
const text = title.getAttribute("data-wallets-private-title") ?? "";
title.title = privateValuesHidden
? createPrivateText(text)
: text;
}
for (const address of addresses) {
const text = address.getAttribute("data-wallets-private-address") ?? "";
const next = privateValuesHidden ? createPrivateText(text) : text;
address.replaceChildren(...createAddress(next).childNodes);
}
for (const input of inputs) {
if (input instanceof HTMLInputElement) {
input.type = privateValuesHidden ? "password" : "text";
}
}
}
/**
* @param {HTMLButtonElement} button
*/
export function syncPrivacyButton(button) {
button.textContent = privateValuesHidden ? "Reveal" : "Privacy";
button.setAttribute("aria-pressed", privateValuesHidden ? "true" : "false");
}
/**
* @param {HTMLElement} root
* @param {HTMLButtonElement} button
* @param {(text: string) => HTMLElement} createAddress
*/
export function togglePrivateValues(root, button, createAddress) {
privateValuesHidden = !privateValuesHidden;
syncPrivateValues(root, createAddress);
syncPrivacyButton(button);
}
@@ -0,0 +1,94 @@
import { mapConcurrent } from "../concurrent.js";
const HISTORY_CONCURRENCY = 4;
const MAX_SELECTED_ADDRESS_TXS = 100;
const historyByBucketKey = new Map();
/**
* @typedef {Object} WalletAddress
* @property {string} address
* @property {number} txCount
* @property {string[]} historyAddresses
* @property {number} historyBucketSize
*/
/**
* @typedef {Object} AddressHistoryClient
* @property {(address: string, options?: { cache?: boolean }) => Promise<unknown>} getAddressTxs
*/
/**
* @typedef {Object} AddressHistory
* @property {unknown[]} transactions
* @property {number} fetchedAddressCount
* @property {number} bucketSize
*/
/**
* @param {readonly string[]} addresses
*/
function createBucketKey(addresses) {
return [...addresses].sort().join("\n");
}
/**
* @param {WalletAddress} address
*/
function assertHistoryIsReasonable(address) {
if (address.txCount > MAX_SELECTED_ADDRESS_TXS) {
throw new Error(
`History disabled for addresses over ${MAX_SELECTED_ADDRESS_TXS} transactions`,
);
}
}
/**
* @param {AddressHistoryClient} client
* @param {readonly string[]} addresses
* @returns {Promise<Map<string, unknown[]>>}
*/
async function fetchBucketHistory(client, addresses) {
const entries = await mapConcurrent(addresses, HISTORY_CONCURRENCY, async (address) => {
const transactions = /** @type {unknown[]} */ (
await client.getAddressTxs(address, { cache: false })
);
return /** @type {const} */ ([address, transactions]);
});
return new Map(entries);
}
/**
* @param {AddressHistoryClient} client
* @param {WalletAddress} address
* @returns {Promise<AddressHistory>}
*/
export async function fetchAddressHistory(client, address) {
assertHistoryIsReasonable(address);
if (address.historyAddresses.length === 0) {
return {
transactions: [],
fetchedAddressCount: 0,
bucketSize: address.historyBucketSize,
};
}
const key = createBucketKey(address.historyAddresses);
let history = historyByBucketKey.get(key);
if (!history) {
history = fetchBucketHistory(client, address.historyAddresses);
historyByBucketKey.set(key, history);
}
const bucketHistory = await history;
return {
transactions: bucketHistory.get(address.address) ?? [],
fetchedAddressCount: address.historyAddresses.length,
bucketSize: address.historyBucketSize,
};
}
@@ -0,0 +1,255 @@
import { mapConcurrent } from "../concurrent.js";
import { rapidHashV3Prefix } from "./rapidhash.js";
const MIN_PREFIX_NIBBLES = 4;
const MAX_PREFIX_NIBBLES = 16;
const LOOKUP_CONCURRENCY = 8;
/**
* @typedef {import("../xpub/index.js").AddressType} AddressType
*/
/**
* @typedef {Object} GeneratedAddress
* @property {number} index
* @property {string} address
* @property {Uint8Array} payload
* @property {string} script
* @property {string} network
* @property {AddressType} addrType
*/
/**
* @typedef {Object} AddressStatsPart
* @property {number} fundedTxoSum
* @property {number} spentTxoSum
* @property {number} txCount
*/
/**
* @typedef {AddressStatsPart & {
* typeIndex: number,
* }} AddressChainStats
*/
/**
* @typedef {Object} AddressStats
* @property {string} address
* @property {AddressChainStats} chainStats
* @property {AddressStatsPart} mempoolStats
*/
/**
* @typedef {Object} AddrHashPrefixMatches
* @property {AddressType} addrType
* @property {string} prefix
* @property {boolean} truncated
* @property {string[]} addresses
*/
/**
* @typedef {Object} WalletAddress
* @property {number} index
* @property {string} address
* @property {string} script
* @property {string} network
* @property {AddressType} addrType
* @property {number} balance
* @property {number} received
* @property {number} sent
* @property {number} txCount
* @property {number | undefined} typeIndex
* @property {string[]} historyAddresses
* @property {number} historyBucketSize
*/
/**
* @typedef {Object} AddressClient
* @property {(address: string, options?: { cache?: boolean }) => Promise<unknown>} getAddress
* @property {(addrType: AddressType, prefix: string, options?: { cache?: boolean }) => Promise<unknown>} getAddressHashPrefixMatches
*/
/**
* @param {AddressStats} stats
*/
function getReceived(stats) {
return stats.chainStats.fundedTxoSum + stats.mempoolStats.fundedTxoSum;
}
/**
* @param {AddressStats} stats
*/
function getSent(stats) {
return stats.chainStats.spentTxoSum + stats.mempoolStats.spentTxoSum;
}
/**
* @param {AddressStats} stats
*/
function getTxCount(stats) {
return stats.chainStats.txCount + stats.mempoolStats.txCount;
}
/**
* @param {GeneratedAddress} generated
* @param {number} historyBucketSize
* @returns {WalletAddress}
*/
function createEmptyWalletAddress(generated, historyBucketSize = 0) {
return {
index: generated.index,
address: generated.address,
script: generated.script,
network: generated.network,
addrType: generated.addrType,
balance: 0,
received: 0,
sent: 0,
txCount: 0,
typeIndex: undefined,
historyAddresses: [],
historyBucketSize,
};
}
/**
* @param {GeneratedAddress} generated
* @param {AddressStats} stats
* @param {readonly string[]} historyAddresses
* @param {number} historyBucketSize
* @returns {WalletAddress}
*/
function createWalletAddress(
generated,
stats,
historyAddresses,
historyBucketSize,
) {
const received = getReceived(stats);
const sent = getSent(stats);
return {
index: generated.index,
address: generated.address,
script: generated.script,
network: generated.network,
addrType: generated.addrType,
balance: received - sent,
received,
sent,
txCount: getTxCount(stats),
typeIndex: stats.chainStats.typeIndex,
historyAddresses: [...historyAddresses],
historyBucketSize,
};
}
/**
* @param {AddressClient} client
* @param {GeneratedAddress} generated
* @param {number} nibbles
* @returns {Promise<AddrHashPrefixMatches>}
*/
async function fetchPrefixMatches(client, generated, nibbles) {
const prefix = rapidHashV3Prefix(generated.payload, nibbles);
return /** @type {AddrHashPrefixMatches} */ (
await client.getAddressHashPrefixMatches(generated.addrType, prefix, {
cache: false,
})
);
}
/**
* @param {AddressClient} client
* @param {GeneratedAddress} generated
* @returns {Promise<AddrHashPrefixMatches>}
*/
async function findUsableBucket(client, generated) {
for (
let nibbles = MIN_PREFIX_NIBBLES;
nibbles <= MAX_PREFIX_NIBBLES;
nibbles += 1
) {
const matches = await fetchPrefixMatches(client, generated, nibbles);
if (matches.truncated) continue;
return matches;
}
throw new Error("Address prefix bucket is too large");
}
/**
* @param {AddressClient} client
* @param {readonly string[]} addresses
* @param {Map<string, Promise<AddressStats>>} cache
*/
async function fetchBucketMetadata(client, addresses, cache) {
for (const address of addresses) {
if (!cache.has(address)) {
cache.set(
address,
client.getAddress(address, { cache: false }).then(
(stats) => /** @type {AddressStats} */ (stats),
),
);
}
}
await Promise.all(addresses.map((address) => cache.get(address)));
}
/**
* @param {AddressClient} client
* @param {GeneratedAddress} generated
* @param {Map<string, Promise<AddressStats>>} metadataCache
* @returns {Promise<WalletAddress>}
*/
async function fetchWalletAddress(client, generated, metadataCache) {
const matches = await findUsableBucket(client, generated);
if (!matches.addresses.includes(generated.address)) {
return createEmptyWalletAddress(generated, matches.addresses.length);
}
await fetchBucketMetadata(client, matches.addresses, metadataCache);
const stats = await metadataCache.get(generated.address);
if (!stats) {
return createEmptyWalletAddress(generated);
}
const historyAddresses = [];
for (const address of matches.addresses) {
const bucketStats = await metadataCache.get(address);
if (bucketStats && getTxCount(bucketStats) > 0) {
historyAddresses.push(address);
}
}
return createWalletAddress(
generated,
stats,
historyAddresses,
matches.addresses.length,
);
}
/**
* @param {AddressClient} client
* @param {readonly GeneratedAddress[]} generated
* @returns {Promise<WalletAddress[]>}
*/
export async function fetchWalletAddresses(client, generated) {
const metadataCache =
/** @type {Map<string, Promise<AddressStats>>} */ (new Map());
return mapConcurrent(generated, LOOKUP_CONCURRENCY, (address) => {
return fetchWalletAddress(client, address, metadataCache);
});
}
+114
View File
@@ -0,0 +1,114 @@
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
*/
export 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);
if (length > 32) {
seed = rapidMix(
readU64(bytes, 16) ^ DEFAULT_SECRETS[2],
readU64(bytes, 24) ^ seed,
);
}
a ^= DEFAULT_SECRETS[1];
b ^= seed;
[a, b] = rapidMum(a, b);
return rapidMix(a ^ 0xaaaaaaaaaaaaaaaan, b ^ DEFAULT_SECRETS[1] ^ BigInt(length));
}
/**
* @param {Uint8Array} bytes
*/
export 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);
}
+119
View File
@@ -0,0 +1,119 @@
import { fetchWalletAddresses } from "./address-lookup.js";
import { generateAddressesFromWalletSource } from "../xpub/index.js";
export const XPUB_GAP_LIMIT = 10;
const SCAN_BATCH_SIZE = XPUB_GAP_LIMIT;
const MAX_SCANNED_ADDRESSES = 1_000;
/**
* @typedef {import("../xpub/address.js").AddressScript} AddressScript
* @typedef {import("../xpub/index.js").AddressType} AddressType
*/
/**
* @typedef {Object} AddressClient
* @property {(address: string, options?: { cache?: boolean }) => Promise<unknown>} getAddress
* @property {(addrType: AddressType, prefix: string, options?: { cache?: boolean }) => Promise<unknown>} getAddressHashPrefixMatches
*/
/**
* @typedef {Object} WalletAddress
* @property {number} index
* @property {string} address
* @property {string} script
* @property {string} network
* @property {AddressType} addrType
* @property {number} balance
* @property {number} received
* @property {number} sent
* @property {number} txCount
* @property {number | undefined} typeIndex
* @property {string[]} historyAddresses
* @property {number} historyBucketSize
*/
/**
* @typedef {Object} ScanProgress
* @property {number} scannedCount
* @property {number} unusedInRow
*/
/**
* @typedef {Object} ScanXpubOptions
* @property {number} start
* @property {AddressScript} script
* @property {readonly number[]} path
* @property {string} [branchId]
* @property {(progress: ScanProgress) => void} [onProgress]
*/
/**
* @typedef {Object} ScanXpubResult
* @property {WalletAddress[]} addresses
* @property {number} scannedCount
* @property {number} gapLimit
* @property {boolean} maxed
*/
/**
* @param {WalletAddress} address
*/
function isUsedAddress(address) {
return address.received > 0 || address.sent > 0 || address.txCount > 0;
}
/**
* @param {AddressClient} client
* @param {string} xpub
* @param {ScanXpubOptions} options
* @returns {Promise<ScanXpubResult>}
*/
export async function scanXpubWallet(client, xpub, options) {
const addresses = /** @type {WalletAddress[]} */ ([]);
let unusedInRow = 0;
let nextStart = options.start;
while (
unusedInRow < XPUB_GAP_LIMIT &&
addresses.length < MAX_SCANNED_ADDRESSES
) {
const count = Math.min(
SCAN_BATCH_SIZE,
XPUB_GAP_LIMIT - unusedInRow,
MAX_SCANNED_ADDRESSES - addresses.length,
);
const generated = await generateAddressesFromWalletSource(xpub, {
start: nextStart,
count,
script: options.script,
path: options.path,
branchId: options.branchId,
});
const batch = /** @type {WalletAddress[]} */ (
await fetchWalletAddresses(client, generated)
);
for (const address of batch) {
addresses.push(address);
unusedInRow = isUsedAddress(address) ? 0 : unusedInRow + 1;
if (unusedInRow >= XPUB_GAP_LIMIT) {
break;
}
}
nextStart += count;
options.onProgress?.({
scannedCount: addresses.length,
unusedInRow,
});
}
return {
addresses,
scannedCount: addresses.length,
gapLimit: XPUB_GAP_LIMIT,
maxed: addresses.length >= MAX_SCANNED_ADDRESSES,
};
}
+187
View File
@@ -0,0 +1,187 @@
import {
scanXpubWallet,
XPUB_GAP_LIMIT,
} from "./xpub-scan.js";
import {
getOutputDescriptorBranchIds,
isOutputDescriptor,
} from "../xpub/index.js";
export const xpubWalletBranches = /** @type {const} */ ([
{ id: "receive", label: "Receive", path: [0] },
{ id: "change", label: "Change", path: [1] },
{ id: "direct", label: "Direct", path: [] },
]);
const descriptorWalletBranches = /** @type {const} */ ([
{ id: "receive", label: "Receive", path: [] },
{ id: "change", label: "Change", path: [] },
]);
const UNKNOWN_TYPE_INDEX = Number.MAX_SAFE_INTEGER;
/**
* @typedef {(typeof xpubWalletBranches[number] | typeof descriptorWalletBranches[number])} WalletBranch
* @typedef {WalletBranch["id"]} WalletBranchId
*/
/**
* @typedef {import("../xpub/address.js").AddressScript} AddressScript
* @typedef {import("../xpub/index.js").AddressType} AddressType
*/
/**
* @typedef {Object} AddressClient
* @property {(address: string, options?: { cache?: boolean }) => Promise<unknown>} getAddress
* @property {(addrType: AddressType, prefix: string, options?: { cache?: boolean }) => Promise<unknown>} getAddressHashPrefixMatches
*/
/**
* @typedef {Object} WalletAddress
* @property {number} index
* @property {string} address
* @property {string} script
* @property {string} network
* @property {AddressType} addrType
* @property {number} balance
* @property {number} received
* @property {number} sent
* @property {number} txCount
* @property {number | undefined} typeIndex
* @property {string[]} historyAddresses
* @property {number} historyBucketSize
*/
/**
* @typedef {WalletAddress & {
* branchId: WalletBranchId,
* branchLabel: string,
* }} XpubWalletAddress
*/
/**
* @typedef {Object} ScanProgress
* @property {WalletBranchId} branchId
* @property {string} branchLabel
* @property {number} scannedCount
* @property {number} unusedInRow
*/
/**
* @typedef {Object} ScanXpubWalletOptions
* @property {number} start
* @property {AddressScript} script
* @property {(progress: ScanProgress) => void} [onProgress]
*/
/**
* @typedef {Object} ScanXpubWalletResult
* @property {XpubWalletAddress[]} addresses
* @property {XpubWalletAddress | undefined} receiveAddress
* @property {number} gapLimit
* @property {boolean} maxed
*/
/**
* @param {WalletAddress} address
*/
function isUsedAddress(address) {
return address.received > 0 || address.sent > 0 || address.txCount > 0;
}
/**
* @param {XpubWalletAddress} address
*/
function getSortIndex(address) {
return address.typeIndex ?? UNKNOWN_TYPE_INDEX;
}
/**
* @param {XpubWalletAddress} a
* @param {XpubWalletAddress} b
*/
function compareWalletAddresses(a, b) {
return getSortIndex(a) - getSortIndex(b) || a.index - b.index;
}
/**
* @param {WalletAddress} address
* @param {WalletBranch} branch
* @returns {XpubWalletAddress}
*/
function addBranch(address, branch) {
return {
...address,
branchId: branch.id,
branchLabel: branch.label,
};
}
/**
* @param {string} xpub
*/
function getWalletBranches(xpub) {
if (!isOutputDescriptor(xpub)) return xpubWalletBranches;
const branchIds = new Set(getOutputDescriptorBranchIds(xpub));
const branches = descriptorWalletBranches.filter((branch) => {
return branchIds.has(branch.id);
});
return branches.length ? branches : [descriptorWalletBranches[0]];
}
/**
* @param {AddressClient} client
* @param {string} xpub
* @param {ScanXpubWalletOptions} options
* @returns {Promise<ScanXpubWalletResult>}
*/
export async function scanXpubBranches(client, xpub, options) {
const addresses = /** @type {XpubWalletAddress[]} */ ([]);
const branches = getWalletBranches(xpub);
const receiveBranch =
branches.find((branch) => branch.id === "receive") ?? branches[0];
/** @type {XpubWalletAddress | undefined} */
let receiveAddress;
let maxed = false;
for (const branch of branches) {
const scan = await scanXpubWallet(client, xpub, {
start: options.start,
script: options.script,
path: branch.path,
branchId: branch.id,
onProgress(progress) {
options.onProgress?.({
branchId: branch.id,
branchLabel: branch.label,
scannedCount: progress.scannedCount,
unusedInRow: progress.unusedInRow,
});
},
});
for (const address of scan.addresses) {
const branchedAddress = addBranch(address, branch);
if (!isUsedAddress(address)) {
if (!receiveAddress && branch.id === receiveBranch.id) {
receiveAddress = branchedAddress;
}
continue;
}
addresses.push(branchedAddress);
}
maxed = maxed || scan.maxed;
}
return {
addresses: addresses.sort(compareWalletAddresses),
receiveAddress,
gapLimit: XPUB_GAP_LIMIT,
maxed,
};
}
+122
View File
@@ -0,0 +1,122 @@
import * as leanQr from "../modules/lean-qr/2.7.1/index.mjs";
import { createGroupedAddress } from "./address-view.js";
import { createElement } from "./dom.js";
import { formatNumber } from "./format.js";
/**
* @typedef {Object} ReceiveAddress
* @property {number} index
* @property {string} address
* @property {string} branchLabel
*/
/**
* @param {ReceiveAddress} receiveAddress
*/
function createReceiveTitle(receiveAddress) {
const title = document.createElement("h2");
title.append(
`${receiveAddress.branchLabel.toLowerCase()} #${formatNumber(receiveAddress.index)}`,
);
return title;
}
/**
* @param {ReceiveAddress} receiveAddress
*/
function createReceiveQr(receiveAddress) {
const image = document.createElement("img");
const uri = `bitcoin:${receiveAddress.address}`;
image.className = "wallets__receive-qr";
image.alt = `QR code for ${receiveAddress.address}`;
// @ts-ignore - lean-qr types do not resolve for file path imports.
image.src = leanQr.generate(uri)?.toDataURL({ scale: 8 }) ?? "";
return image;
}
/**
* @param {ReceiveAddress} receiveAddress
*/
function createReceiveAddress(receiveAddress) {
const element = createElement("div", "wallets__receive-address");
element.append(createGroupedAddress(receiveAddress.address));
return element;
}
/**
* @param {ReceiveAddress} receiveAddress
* @param {HTMLButtonElement} copy
*/
async function copyReceiveAddress(receiveAddress, copy) {
await navigator.clipboard.writeText(receiveAddress.address);
copy.textContent = "Copied";
}
/**
* @param {ReceiveAddress} receiveAddress
*/
function openReceiveDialog(receiveAddress) {
const main = document.querySelector("main.wallets") ?? document.body;
const dialog = createElement("dialog", "wallets__dialog wallets__receive-dialog");
const content = createElement("div", "wallets__receive-card");
const actions = createElement("div", "wallets__receive-actions");
const copy = document.createElement("button");
const close = document.createElement("button");
copy.type = "button";
copy.append("Copy");
close.type = "button";
close.append("Close");
actions.append(copy, close);
content.append(
createReceiveTitle(receiveAddress),
createReceiveQr(receiveAddress),
createReceiveAddress(receiveAddress),
actions,
);
dialog.append(content);
main.append(dialog);
copy.addEventListener("click", () => {
void copyReceiveAddress(receiveAddress, copy).catch(() => {
copy.textContent = "Copy failed";
});
});
close.addEventListener("click", () => {
dialog.close();
});
dialog.addEventListener("close", () => {
dialog.remove();
});
dialog.addEventListener("click", (event) => {
if (event.target === dialog) {
dialog.close();
}
});
dialog.showModal();
}
/**
* @param {HTMLElement} element
* @param {ReceiveAddress | undefined} receiveAddress
*/
export function renderReceiveButton(element, receiveAddress) {
const button = document.createElement("button");
button.type = "button";
button.className = "wallets__receive-button";
button.disabled = !receiveAddress;
button.append("Receive");
button.addEventListener("click", () => {
if (receiveAddress) {
openReceiveDialog(receiveAddress);
}
});
element.append(button);
}
+72
View File
@@ -0,0 +1,72 @@
main.wallets {
.wallets__receive-button,
.wallets__receive-actions button {
min-width: 0;
height: var(--control-height);
border: 1px solid var(--orange);
border-radius: 0.375rem;
padding: 0 0.875rem;
color: var(--black);
background: var(--orange);
font: inherit;
line-height: 1;
cursor: pointer;
}
.wallets__receive-button:disabled {
border-color: color-mix(in oklch, var(--gray) 35%, transparent);
color: var(--gray);
background: transparent;
cursor: default;
}
.wallets__receive-button:focus-visible,
.wallets__receive-actions button:focus-visible {
outline: 2px solid var(--orange);
outline-offset: 2px;
}
.wallets__receive-dialog {
width: min(100% - 2rem, 32rem);
}
.wallets__receive-card {
display: grid;
gap: 1rem;
h2 {
margin: 0;
font-size: var(--font-size-lg);
font-weight: 400;
line-height: var(--line-height-lg);
}
}
.wallets__receive-qr {
justify-self: center;
width: min(100%, 18rem);
aspect-ratio: 1;
padding: 1rem;
background: var(--white);
image-rendering: pixelated;
}
.wallets__receive-address {
color: var(--white);
font-size: var(--font-size-sm);
line-height: var(--line-height-sm);
}
.wallets__receive-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: end;
button:last-child {
border-color: color-mix(in oklch, var(--gray) 45%, transparent);
color: var(--white);
background: color-mix(in oklch, var(--black) 72%, var(--white));
}
}
}
+89
View File
@@ -0,0 +1,89 @@
import {
setBusy,
setStatus,
} from "./dom.js";
import { getErrorMessage } from "./errors.js";
import { formatNumber } from "./format.js";
import {
scanXpubBranches,
} from "./privacy/xpub-wallet.js";
import { XPUB_GAP_LIMIT } from "./privacy/xpub-scan.js";
/**
* @typedef {import("./xpub/address.js").AddressScript} AddressScript
* @typedef {import("./xpub/index.js").AddressType} AddressType
* @typedef {Awaited<ReturnType<typeof scanXpubBranches>>["addresses"][number]} WalletAddress
*/
/**
* @typedef {Object} WalletScan
* @property {WalletAddress[]} addresses
* @property {WalletAddress | undefined} receiveAddress
* @property {number} btcUsdPrice
*/
/**
* @typedef {Object} WalletScanClient
* @property {(address: string, options?: { cache?: boolean }) => Promise<unknown>} getAddress
* @property {(addrType: AddressType, prefix: string, options?: { cache?: boolean }) => Promise<unknown>} getAddressHashPrefixMatches
* @property {(options?: { cache?: boolean }) => Promise<unknown>} getLivePrice
*/
export function createScanPendingMessage() {
return `Scanning until ${XPUB_GAP_LIMIT} unused addresses`;
}
/**
* @param {Object} options
* @param {WalletScanClient} options.client
* @param {string} options.xpub
* @param {number} options.start
* @param {AddressScript} options.script
* @param {HTMLButtonElement} [options.button]
* @param {HTMLElement} options.status
* @returns {Promise<WalletScan | undefined>}
*/
export async function scanWalletAddresses({
client,
xpub,
start,
script,
button,
status,
}) {
if (button) {
setBusy(button, true, "Scan", "Scanning");
}
setStatus(status, createScanPendingMessage());
try {
const scan = await scanXpubBranches(client, xpub, {
start,
script,
onProgress(progress) {
setStatus(
status,
`${progress.branchLabel}: scanned ${formatNumber(progress.scannedCount)} addresses, ${progress.unusedInRow}/${XPUB_GAP_LIMIT} unused`,
);
},
});
const addresses = /** @type {WalletAddress[]} */ (scan.addresses);
const btcUsdPrice = /** @type {number} */ (
await client.getLivePrice({ cache: false })
);
setStatus(status, "Ready");
return {
addresses,
receiveAddress: scan.receiveAddress,
btcUsdPrice,
};
} catch (error) {
setStatus(status, getErrorMessage(error));
} finally {
if (button) {
setBusy(button, false, "Scan", "Scanning");
}
}
}
+49
View File
@@ -0,0 +1,49 @@
import { addressScripts } from "./address-scripts.js";
import { fetchWalletAddresses } from "./privacy/address-lookup.js";
import {
generateAddressesFromXpub,
isOutputDescriptor,
} from "./xpub/index.js";
import { parseOutputDescriptor } from "./xpub/descriptor.js";
const RECEIVE_PATH = /** @type {const} */ ([0]);
/**
* @typedef {import("./xpub/address.js").AddressScript} AddressScript
* @typedef {import("./privacy/xpub-scan.js").AddressClient} AddressClient
* @typedef {import("./privacy/address-lookup.js").WalletAddress} WalletAddress
*/
/**
* @param {WalletAddress} address
*/
function hasHistory(address) {
return address.received > 0 || address.sent > 0 || address.txCount > 0;
}
/**
* @param {AddressClient} client
* @param {string} xpub
* @returns {Promise<AddressScript>}
*/
export async function inferAddressScript(client, xpub) {
if (isOutputDescriptor(xpub)) {
return parseOutputDescriptor(xpub).script;
}
for (const { id } of addressScripts) {
const generated = await generateAddressesFromXpub(xpub, {
start: 0,
count: 1,
script: id,
path: RECEIVE_PATH,
});
const [address] = await fetchWalletAddresses(client, generated);
if (address && hasHistory(address)) {
return id;
}
}
return addressScripts[0].id;
}
+96
View File
@@ -0,0 +1,96 @@
/**
* @typedef {Object} StoredWallet
* @property {string} id
* @property {string} name
*/
/**
* @typedef {Object} WalletSelectorRenderOptions
* @property {StoredWallet[]} wallets
* @property {string} selectedWalletId
* @property {(walletId: string) => void} onSelect
*/
/**
* @typedef {Object} WalletSelectorInteractionOptions
* @property {() => string} getSelectedWalletId
* @property {(walletId: string) => void} onSelect
*/
/**
* @param {HTMLElement} walletList
* @param {WalletSelectorRenderOptions} options
*/
export function renderWalletSelector(walletList, options) {
walletList.replaceChildren();
for (const wallet of options.wallets) {
const button = document.createElement("button");
const selected = wallet.id === options.selectedWalletId;
button.type = "button";
button.className = "wallets__wallet-button";
button.setAttribute("aria-pressed", selected ? "true" : "false");
button.setAttribute("data-wallet-id", wallet.id);
button.append(wallet.name);
button.addEventListener("click", () => {
options.onSelect(wallet.id);
});
walletList.append(button);
}
}
/**
* @param {HTMLElement} walletList
* @param {WalletSelectorInteractionOptions} options
*/
export function initWalletSelector(walletList, options) {
function selectSnappedWallet() {
const buttons = [...walletList.querySelectorAll(".wallets__wallet-button")];
if (buttons.length === 0) return;
const listRect = walletList.getBoundingClientRect();
const listCenter = listRect.left + listRect.width / 2;
const closest = buttons.reduce((best, button) => {
const rect = button.getBoundingClientRect();
const center = rect.left + rect.width / 2;
const distance = Math.abs(center - listCenter);
return distance < best.distance
? { button, distance }
: best;
}, {
button: buttons[0],
distance: Number.POSITIVE_INFINITY,
});
const id = closest.button.getAttribute("data-wallet-id");
if (id && id !== options.getSelectedWalletId()) {
options.onSelect(id);
}
}
walletList.addEventListener("scrollend", () => {
selectSnappedWallet();
});
walletList.addEventListener("wheel", (event) => {
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY)
? event.deltaX
: event.deltaY;
if (delta === 0) return;
const maxScrollLeft = walletList.scrollWidth - walletList.clientWidth;
const nextScrollLeft = Math.max(
0,
Math.min(maxScrollLeft, walletList.scrollLeft + delta),
);
if (nextScrollLeft === walletList.scrollLeft) return;
event.preventDefault();
walletList.scrollLeft = nextScrollLeft;
}, { passive: false });
}
+56
View File
@@ -0,0 +1,56 @@
main.wallets {
.wallets__selector {
min-width: 0;
}
.wallets__wallet-list {
display: flex;
gap: 1rem;
min-width: 0;
overflow-x: auto;
padding-bottom: 0.25rem;
overscroll-behavior-inline: contain;
scroll-padding-inline: var(--page-x);
scroll-snap-type: x proximity;
}
.wallets__wallet-button {
flex: 0 0 auto;
scroll-snap-align: center;
border: 0;
padding: 0;
color: var(--white);
background: transparent;
font-family: var(--font-serif);
font-size: 4rem;
font-weight: 400;
line-height: 1;
opacity: 0.48;
cursor: pointer;
}
.wallets__wallet-button[aria-pressed="true"] {
opacity: 1;
}
.wallets__wallet-button:focus-visible {
outline: 2px solid var(--orange);
outline-offset: 2px;
}
}
@media (max-width: 56rem) {
main.wallets {
.wallets__wallet-button {
font-size: 3rem;
}
}
}
@media (max-width: 34rem) {
main.wallets {
.wallets__wallet-button {
font-size: 2.5rem;
}
}
}
+42
View File
@@ -0,0 +1,42 @@
import {
createAddressScriptSelect,
readAddressScript,
} from "./address-scripts.js";
import {
createElement,
createField,
} from "./dom.js";
import { isOutputDescriptor } from "./xpub/index.js";
/**
* @typedef {import("./address-scripts.js").AddressScript} AddressScript
* @typedef {import("./storage/wallets.js").StoredWallet} StoredWallet
*/
/**
* @typedef {Object} WalletSettingsOptions
* @property {(script: AddressScript, select: HTMLSelectElement, status: HTMLElement) => void | Promise<void>} onScriptChange
*/
/**
* @param {HTMLElement} element
* @param {StoredWallet} wallet
* @param {WalletSettingsOptions} options
*/
export function renderWalletSettings(element, wallet, options) {
if (isOutputDescriptor(wallet.xpub)) {
element.replaceChildren();
return;
}
const script = createAddressScriptSelect(
/** @type {AddressScript} */ (wallet.script),
);
const status = createElement("p", "wallets__status wallets__settings-status");
status.setAttribute("role", "status");
script.addEventListener("change", () => {
void options.onScriptChange(readAddressScript(script), script, status);
});
element.replaceChildren(createField("Address type", script), status);
}
+21
View File
@@ -0,0 +1,21 @@
main.wallets {
.wallets__settings {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: end;
justify-content: end;
}
.wallets__settings .wallets__field {
min-width: min(100%, 14rem);
}
}
@media (max-width: 34rem) {
main.wallets {
.wallets__settings {
justify-content: start;
}
}
}
+160
View File
@@ -0,0 +1,160 @@
const ENCRYPTION_VERSION = 1;
const PBKDF2_ITERATIONS = 250_000;
const KEY_BITS = 256;
const SALT_BYTES = 16;
const IV_BYTES = 12;
const encoder = new TextEncoder();
const decoder = new TextDecoder();
/**
* @typedef {Object} EncryptedSecret
* @property {1} version
* @property {"PBKDF2-SHA256"} kdf
* @property {number} iterations
* @property {"AES-GCM"} cipher
* @property {string} salt
* @property {string} iv
* @property {string} ciphertext
*/
/**
* @param {Uint8Array} bytes
*/
function toArrayBuffer(bytes) {
const buffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(buffer).set(bytes);
return buffer;
}
/**
* @param {Uint8Array} bytes
*/
function bytesToBase64(bytes) {
let binary = "";
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
/**
* @param {string} base64
*/
function base64ToBytes(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
/**
* @param {number} length
*/
function randomBytes(length) {
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
return bytes;
}
/**
* @param {string} password
*/
async function importPassword(password) {
return crypto.subtle.importKey(
"raw",
encoder.encode(password),
"PBKDF2",
false,
["deriveKey"],
);
}
/**
* @param {string} password
* @param {Uint8Array} salt
* @param {number} iterations
*/
async function deriveKey(password, salt, iterations) {
const key = await importPassword(password);
return crypto.subtle.deriveKey(
{
name: "PBKDF2",
hash: "SHA-256",
salt: toArrayBuffer(salt),
iterations,
},
key,
{
name: "AES-GCM",
length: KEY_BITS,
},
false,
["encrypt", "decrypt"],
);
}
/**
* @param {string} secret
* @param {string} password
* @returns {Promise<EncryptedSecret>}
*/
export async function encryptSecret(secret, password) {
const salt = randomBytes(SALT_BYTES);
const iv = randomBytes(IV_BYTES);
const key = await deriveKey(password, salt, PBKDF2_ITERATIONS);
const encrypted = await crypto.subtle.encrypt(
{
name: "AES-GCM",
iv: toArrayBuffer(iv),
},
key,
encoder.encode(secret),
);
return {
version: ENCRYPTION_VERSION,
kdf: "PBKDF2-SHA256",
iterations: PBKDF2_ITERATIONS,
cipher: "AES-GCM",
salt: bytesToBase64(salt),
iv: bytesToBase64(iv),
ciphertext: bytesToBase64(new Uint8Array(encrypted)),
};
}
/**
* @param {EncryptedSecret} encrypted
* @param {string} password
*/
export async function decryptSecret(encrypted, password) {
if (encrypted.version !== ENCRYPTION_VERSION) {
throw new Error("Unsupported wallet encryption version");
}
const salt = base64ToBytes(encrypted.salt);
const iv = base64ToBytes(encrypted.iv);
const ciphertext = base64ToBytes(encrypted.ciphertext);
const key = await deriveKey(password, salt, encrypted.iterations);
const decrypted = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: toArrayBuffer(iv),
},
key,
toArrayBuffer(ciphertext),
);
return decoder.decode(decrypted);
}
+155
View File
@@ -0,0 +1,155 @@
import { decryptSecret, encryptSecret } from "./encryption.js";
const STORAGE_KEY = "bitview.wallets.v2";
/**
* @typedef {import("./encryption.js").EncryptedSecret} EncryptedSecret
* @typedef {import("../xpub/address.js").AddressScript} AddressScript
*/
/**
* @typedef {Object} StoredWallet
* @property {string} id
* @property {string} name
* @property {AddressScript} script
* @property {string} xpub
* @property {number} createdAt
* @property {number} updatedAt
*/
/**
* @typedef {Object} AddWalletInput
* @property {string} name
* @property {AddressScript} script
* @property {string} xpub
*/
/**
* @typedef {Object} UpdateWalletScriptInput
* @property {string} walletId
* @property {AddressScript} script
*/
/**
* @typedef {Object} WalletVault
* @property {StoredWallet[]} wallets
*/
/**
* @param {unknown} value
* @returns {EncryptedSecret | undefined}
*/
function readEncryptedVault(value) {
return value && typeof value === "object"
? /** @type {EncryptedSecret} */ (value)
: undefined;
}
/**
* @param {unknown} value
* @returns {WalletVault}
*/
function readVault(value) {
if (!value || typeof value !== "object" || !("wallets" in value)) {
return { wallets: [] };
}
return Array.isArray(value.wallets)
? /** @type {WalletVault} */ (value)
: { wallets: [] };
}
function createWalletId() {
return crypto.randomUUID();
}
function now() {
return Date.now();
}
export function hasStoredWallets() {
return Boolean(localStorage.getItem(STORAGE_KEY));
}
export function resetWalletVault() {
localStorage.removeItem(STORAGE_KEY);
}
/**
* @param {string} pagePassword
*/
export async function createWalletVault(pagePassword) {
await writeWallets([], pagePassword);
}
/**
* @param {string} pagePassword
*/
export async function loadWallets(pagePassword) {
const value = localStorage.getItem(STORAGE_KEY);
const encrypted = value ? readEncryptedVault(JSON.parse(value)) : undefined;
if (!encrypted) return [];
return readVault(JSON.parse(await decryptSecret(encrypted, pagePassword))).wallets;
}
/**
* @param {StoredWallet[]} wallets
* @param {string} pagePassword
*/
async function writeWallets(wallets, pagePassword) {
localStorage.setItem(
STORAGE_KEY,
JSON.stringify(
await encryptSecret(JSON.stringify({ wallets }), pagePassword),
),
);
}
/**
* @param {StoredWallet[]} wallets
* @param {AddWalletInput} input
* @param {string} pagePassword
*/
export async function addWallet(wallets, input, pagePassword) {
const time = now();
const wallet = {
id: createWalletId(),
name: input.name.trim(),
script: input.script,
xpub: input.xpub.trim(),
createdAt: time,
updatedAt: time,
};
const nextWallets = [...wallets, wallet];
await writeWallets(nextWallets, pagePassword);
return {
wallet,
wallets: nextWallets,
};
}
/**
* @param {StoredWallet[]} wallets
* @param {UpdateWalletScriptInput} input
* @param {string} pagePassword
*/
export async function updateWalletScript(wallets, input, pagePassword) {
const time = now();
const nextWallets = wallets.map((wallet) => {
return wallet.id === input.walletId
? {
...wallet,
script: input.script,
updatedAt: time,
}
: wallet;
});
await writeWallets(nextWallets, pagePassword);
return nextWallets;
}
+58
View File
@@ -0,0 +1,58 @@
main.wallets {
--offset: 4rem;
--content-width: 72rem;
--control-height: 2.75rem;
display: grid;
gap: 1.5rem;
align-content: start;
width: min(100%, var(--content-width));
margin-inline: auto;
padding: var(--offset) var(--page-x);
scroll-padding-top: var(--offset);
&[data-wallets-page-locked] {
min-height: 100dvh;
align-content: center;
}
.wallets__header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 1rem;
align-items: center;
}
.wallets__actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
justify-content: end;
}
.wallets__content {
display: grid;
gap: 1.5rem;
}
.wallets__status {
min-height: var(--line-height-sm);
margin: 0;
color: var(--gray);
font-size: var(--font-size-sm);
line-height: var(--line-height-sm);
}
}
@media (max-width: 34rem) {
main.wallets {
.wallets__header {
display: grid;
grid-template-columns: 1fr;
}
.wallets__actions {
justify-content: start;
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import { createElement } from "./dom.js";
import { formatBtc, formatUsd } from "./format.js";
import { createPrivateValue } from "./privacy-view.js";
/**
* @typedef {Object} WalletAddress
* @property {number} balance
*/
/**
* @param {number} balance
* @param {number} btcUsdPrice
*/
function createBalanceSummary(balance, btcUsdPrice) {
const element = createElement("p", "wallets__metric wallets__balance");
const btc = createPrivateValue("strong", formatBtc(balance), "fixed");
const usd = createPrivateValue(
"span",
formatUsd((balance / 100_000_000) * btcUsdPrice),
"fixed",
);
element.append(btc, usd);
return element;
}
/**
* @param {HTMLElement} summary
* @param {WalletAddress[]} addresses
* @param {number} btcUsdPrice
*/
export function renderWalletSummary(summary, addresses, btcUsdPrice) {
const balance = addresses.reduce((total, row) => total + row.balance, 0);
summary.replaceChildren(createBalanceSummary(balance, btcUsdPrice));
}
+51
View File
@@ -0,0 +1,51 @@
main.wallets {
.wallets__summary {
min-height: 5rem;
}
.wallets__metric {
display: grid;
gap: 0.375rem;
margin: 0;
strong {
min-width: 0;
overflow-wrap: anywhere;
color: var(--white);
font-size: 1.5rem;
font-weight: 620;
line-height: 1;
}
span {
color: var(--gray);
font-size: var(--font-size-xs);
line-height: var(--line-height-xs);
text-transform: uppercase;
}
}
.wallets__balance {
gap: 0.5rem;
strong {
font-size: 3rem;
}
span {
font-size: var(--font-size-lg);
line-height: var(--line-height-lg);
text-transform: none;
}
}
}
@media (max-width: 34rem) {
main.wallets {
.wallets__balance {
strong {
font-size: 2.25rem;
}
}
}
}
+140
View File
@@ -0,0 +1,140 @@
import { createAddressCellContent } from "./address-view.js";
import { createElement } from "./dom.js";
import { formatBtc } from "./format.js";
import {
createHistoryContent,
createHistoryMessage,
createHistoryRow,
replaceHistoryRowContent,
} from "./history-view.js";
import { createPrivateValue } from "./privacy-view.js";
const ADDRESS_TABLE_COLUMN_COUNT = 3;
/**
* @typedef {Object} WalletAddress
* @property {number} index
* @property {string} address
* @property {number} balance
* @property {number} txCount
* @property {string[]} historyAddresses
* @property {number} historyBucketSize
*/
/**
* @typedef {Object} AddressHistory
* @property {unknown[]} transactions
*/
/**
* @typedef {Object} AddressTableOptions
* @property {(address: WalletAddress) => Promise<AddressHistory>} fetchHistory
* @property {(error: unknown) => string} getErrorMessage
*/
/**
* @param {HTMLTableRowElement} row
* @param {Node | string} value
*/
function appendCell(row, value) {
const cell = document.createElement("td");
cell.append(value);
row.append(cell);
}
/**
* @param {WalletAddress} address
* @param {HTMLTableRowElement} parent
* @param {AddressTableOptions} options
*/
function createHistoryButton(address, parent, options) {
if (address.txCount === 0) {
return "";
}
const button = document.createElement("button");
/** @type {HTMLTableRowElement | undefined} */
let historyRow;
button.type = "button";
button.className = "wallets__history-button";
button.append("History");
button.addEventListener("click", async () => {
if (historyRow?.isConnected) {
historyRow.remove();
button.textContent = "History";
return;
}
button.disabled = true;
button.textContent = "Loading";
historyRow = createHistoryRow(
createHistoryMessage("Loading history"),
ADDRESS_TABLE_COLUMN_COUNT,
);
parent.after(historyRow);
try {
const history = await options.fetchHistory(address);
replaceHistoryRowContent(
historyRow,
createHistoryContent(history, address.address),
ADDRESS_TABLE_COLUMN_COUNT,
);
button.textContent = "Hide";
} catch (error) {
replaceHistoryRowContent(
historyRow,
createHistoryMessage(options.getErrorMessage(error)),
ADDRESS_TABLE_COLUMN_COUNT,
);
button.textContent = "History";
} finally {
button.disabled = false;
}
});
return button;
}
/**
* @param {WalletAddress[]} addresses
* @param {AddressTableOptions} options
* @returns {HTMLTableElement}
*/
export function createAddressTable(addresses, options) {
const table = createElement("table", "wallets__table");
const head = document.createElement("thead");
const body = document.createElement("tbody");
const header = document.createElement("tr");
for (const value of [
"address",
"balance",
"history",
]) {
const cell = document.createElement("th");
cell.scope = "col";
cell.append(value);
header.append(cell);
}
head.append(header);
for (const row of addresses) {
const item = document.createElement("tr");
appendCell(item, createAddressCellContent(row));
appendCell(item, createPrivateValue("span", formatBtc(row.balance), "fixed"));
appendCell(item, createHistoryButton(row, item, options));
body.append(item);
}
table.append(head, body);
return table;
}
+38
View File
@@ -0,0 +1,38 @@
main.wallets {
.wallets__results {
display: grid;
gap: 1rem;
overflow-x: auto;
}
.wallets__table {
width: 100%;
min-width: 44rem;
border-collapse: collapse;
font-size: var(--font-size-sm);
line-height: var(--line-height-sm);
th,
td {
padding: 0.75rem;
border-bottom: 1px solid color-mix(in oklch, var(--gray) 22%, transparent);
text-align: left;
vertical-align: top;
}
th {
color: var(--gray);
font-size: var(--font-size-xs);
font-weight: 400;
line-height: var(--line-height-xs);
text-transform: uppercase;
}
code {
overflow-wrap: anywhere;
color: var(--white);
font-family: inherit;
font-size: inherit;
}
}
}
+23
View File
@@ -0,0 +1,23 @@
import { isOutputDescriptor } from "./xpub/index.js";
const EXTENDED_PUBLIC_KEY_PATTERN =
/\b(?:xpub|ypub|zpub|tpub|upub|vpub)[1-9A-HJ-NP-Za-km-z]{20,}\b/;
/**
* @param {string} text
*/
export function readWalletSourceText(text) {
const value = text.trim();
if (isOutputDescriptor(value)) {
return value;
}
const match = value.match(EXTENDED_PUBLIC_KEY_PATTERN);
if (match) {
return match[0];
}
throw new Error("Expected an xpub or descriptor");
}
+311
View File
@@ -0,0 +1,311 @@
import { encodeBase58Check } from "./base58.js";
import { concatBytes } from "./bytes.js";
import { hash160, sha256 } from "./hash.js";
import {
addXOnlyPublicKeyTweak,
getXOnlyPublicKey,
} from "./secp256k1.js";
const bech32Alphabet = "qpzry9x8gf2tvdw0s3jn54khce6mua7l";
const bech32Generator = /** @type {const} */ ([
0x3b6a57b2,
0x26508e6d,
0x1ea119fa,
0x3d4233dd,
0x2a1462b3,
]);
const BECH32_CHECKSUM = 1;
const BECH32M_CHECKSUM = 0x2bc830a3;
const p2pkhVersions = /** @type {const} */ ({
mainnet: 0x00,
testnet: 0x6f,
});
const p2shVersions = /** @type {const} */ ({
mainnet: 0x05,
testnet: 0xc4,
});
const bech32Prefixes = /** @type {const} */ ({
mainnet: "bc",
testnet: "tb",
});
/**
* @typedef {"mainnet" | "testnet"} BitcoinNetwork
* @typedef {"p2pkh" | "p2sh_p2wpkh" | "v0_p2wpkh" | "v1_p2tr" | "v0_p2wsh_sortedmulti"} AddressScript
* @typedef {Object} EncodedAddress
* @property {string} address
* @property {Uint8Array} payload
*/
/**
* @param {number} version
* @param {Uint8Array} payload
*/
function encodeVersionedBase58(version, payload) {
return encodeBase58Check(concatBytes([Uint8Array.of(version), payload]));
}
/**
* @param {string} prefix
*/
function expandBech32Prefix(prefix) {
const values = /** @type {number[]} */ ([]);
for (const character of prefix) {
values.push(character.charCodeAt(0) >> 5);
}
values.push(0);
for (const character of prefix) {
values.push(character.charCodeAt(0) & 31);
}
return values;
}
/**
* @param {number[]} values
*/
function bech32Polymod(values) {
let checksum = 1;
for (const value of values) {
const top = checksum >>> 25;
checksum = ((checksum & 0x1ffffff) << 5) ^ value;
for (let i = 0; i < bech32Generator.length; i += 1) {
if ((top >>> i) & 1) {
checksum ^= bech32Generator[i];
}
}
}
return checksum;
}
/**
* @param {string} prefix
* @param {number[]} values
* @param {number} checksumConstant
*/
function createBech32Checksum(prefix, values, checksumConstant) {
const polymod = bech32Polymod([
...expandBech32Prefix(prefix),
...values,
0,
0,
0,
0,
0,
0,
]);
const checksum = /** @type {number[]} */ ([]);
const combined = polymod ^ checksumConstant;
for (let i = 0; i < 6; i += 1) {
checksum.push((combined >>> (5 * (5 - i))) & 31);
}
return checksum;
}
/**
* @param {Uint8Array} bytes
* @param {number} fromBits
* @param {number} toBits
*/
function convertBits(bytes, fromBits, toBits) {
const maxValue = (1 << toBits) - 1;
const values = /** @type {number[]} */ ([]);
let accumulator = 0;
let bits = 0;
for (const byte of bytes) {
accumulator = (accumulator << fromBits) | byte;
bits += fromBits;
while (bits >= toBits) {
bits -= toBits;
values.push((accumulator >>> bits) & maxValue);
}
}
if (bits > 0) {
values.push((accumulator << (toBits - bits)) & maxValue);
}
return values;
}
/**
* @param {string} prefix
* @param {number[]} values
* @param {number} checksumConstant
*/
function encodeBech32(prefix, values, checksumConstant) {
const checksum = createBech32Checksum(prefix, values, checksumConstant);
const characters = [...values, ...checksum].map((value) => {
return bech32Alphabet[value];
});
return `${prefix}1${characters.join("")}`;
}
/**
* @param {string} tag
* @param {Uint8Array} bytes
*/
async function taggedHash(tag, bytes) {
const tagHash = await sha256(new TextEncoder().encode(tag));
return sha256(concatBytes([tagHash, tagHash, bytes]));
}
/**
* @param {Uint8Array} publicKey
* @param {BitcoinNetwork} network
* @returns {Promise<EncodedAddress>}
*/
export async function encodeP2pkhAddressData(publicKey, network) {
const payload = await hash160(publicKey);
return {
address: await encodeVersionedBase58(p2pkhVersions[network], payload),
payload,
};
}
/**
* @param {Uint8Array} publicKey
* @param {BitcoinNetwork} network
*/
export async function encodeP2pkhAddress(publicKey, network) {
return (await encodeP2pkhAddressData(publicKey, network)).address;
}
/**
* @param {Uint8Array} publicKey
* @param {BitcoinNetwork} network
* @returns {Promise<EncodedAddress>}
*/
export async function encodeP2shP2wpkhAddressData(publicKey, network) {
const publicKeyHash = await hash160(publicKey);
const redeemScript = concatBytes([Uint8Array.of(0x00, 0x14), publicKeyHash]);
const payload = await hash160(redeemScript);
return {
address: await encodeVersionedBase58(p2shVersions[network], payload),
payload,
};
}
/**
* @param {Uint8Array} publicKey
* @param {BitcoinNetwork} network
*/
export async function encodeP2shP2wpkhAddress(publicKey, network) {
return (await encodeP2shP2wpkhAddressData(publicKey, network)).address;
}
/**
* @param {Uint8Array} publicKey
* @param {BitcoinNetwork} network
* @returns {Promise<EncodedAddress>}
*/
export async function encodeP2wpkhAddressData(publicKey, network) {
const payload = await hash160(publicKey);
const values = [0, ...convertBits(payload, 8, 5)];
return {
address: encodeBech32(bech32Prefixes[network], values, BECH32_CHECKSUM),
payload,
};
}
/**
* @param {Uint8Array} publicKey
* @param {BitcoinNetwork} network
*/
export async function encodeP2wpkhAddress(publicKey, network) {
return (await encodeP2wpkhAddressData(publicKey, network)).address;
}
/**
* @param {Uint8Array} witnessScript
* @param {BitcoinNetwork} network
* @returns {Promise<EncodedAddress>}
*/
export async function encodeP2wshAddressData(witnessScript, network) {
const payload = await sha256(witnessScript);
const values = [0, ...convertBits(payload, 8, 5)];
return {
address: encodeBech32(bech32Prefixes[network], values, BECH32_CHECKSUM),
payload,
};
}
/**
* @param {Uint8Array} publicKey
* @param {BitcoinNetwork} network
* @returns {Promise<EncodedAddress>}
*/
export async function encodeP2trAddressData(publicKey, network) {
const internalKey = getXOnlyPublicKey(publicKey);
const tweak = await taggedHash("TapTweak", internalKey);
const payload = addXOnlyPublicKeyTweak(publicKey, tweak);
const values = [1, ...convertBits(payload, 8, 5)];
return {
address: encodeBech32(bech32Prefixes[network], values, BECH32M_CHECKSUM),
payload,
};
}
/**
* @param {Uint8Array} publicKey
* @param {BitcoinNetwork} network
*/
export async function encodeP2trAddress(publicKey, network) {
return (await encodeP2trAddressData(publicKey, network)).address;
}
/**
* @param {Uint8Array} publicKey
* @param {AddressScript} script
* @param {BitcoinNetwork} network
* @returns {Promise<EncodedAddress>}
*/
export async function encodePublicKeyAddressData(publicKey, script, network) {
if (script === "p2pkh") {
return encodeP2pkhAddressData(publicKey, network);
}
if (script === "p2sh_p2wpkh") {
return encodeP2shP2wpkhAddressData(publicKey, network);
}
if (script === "v0_p2wpkh") {
return encodeP2wpkhAddressData(publicKey, network);
}
if (script === "v1_p2tr") {
return encodeP2trAddressData(publicKey, network);
}
throw new Error("Expected a single-key address script");
}
/**
* @param {Uint8Array} publicKey
* @param {AddressScript} script
* @param {BitcoinNetwork} network
*/
export async function encodePublicKeyAddress(publicKey, script, network) {
return (await encodePublicKeyAddressData(publicKey, script, network)).address;
}
+114
View File
@@ -0,0 +1,114 @@
import { bytesEqual, bytesToBigInt, concatBytes, createBytes } from "./bytes.js";
import { checksum as createChecksum } from "./hash.js";
const alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
const base = 58n;
/**
* @param {string} character
*/
function readBase58Character(character) {
const index = alphabet.indexOf(character);
if (index === -1) {
throw new Error(`Invalid Base58 character: ${character}`);
}
return index;
}
/**
* @param {string} text
*/
function countLeadingZeros(text) {
let count = 0;
while (text[count] === "1") {
count += 1;
}
return count;
}
/**
* @param {Uint8Array} bytes
*/
function countLeadingZeroBytes(bytes) {
let count = 0;
while (bytes[count] === 0) {
count += 1;
}
return count;
}
/**
* @param {string} text
*/
export function decodeBase58(text) {
let value = 0n;
for (const character of text) {
value = value * base + BigInt(readBase58Character(character));
}
const leadingZeros = countLeadingZeros(text);
const decoded = /** @type {number[]} */ ([]);
while (value > 0n) {
decoded.push(Number(value & 0xffn));
value >>= 8n;
}
decoded.reverse();
const bytes = createBytes(leadingZeros + decoded.length);
bytes.set(decoded, leadingZeros);
return bytes;
}
/**
* @param {Uint8Array} bytes
*/
export function encodeBase58(bytes) {
let value = bytesToBigInt(bytes);
let text = "";
while (value > 0n) {
const index = Number(value % base);
text = alphabet[index] + text;
value /= base;
}
return "1".repeat(countLeadingZeroBytes(bytes)) + text;
}
/**
* @param {string} text
*/
export async function decodeBase58Check(text) {
const bytes = decodeBase58(text);
if (bytes.length < 4) {
throw new Error("Invalid Base58Check payload");
}
const payload = bytes.slice(0, -4);
const expected = await createChecksum(payload);
const actual = bytes.slice(-4);
if (!bytesEqual(actual, expected)) {
throw new Error("Invalid Base58Check checksum");
}
return payload;
}
/**
* @param {Uint8Array} payload
*/
export async function encodeBase58Check(payload) {
return encodeBase58(concatBytes([payload, await createChecksum(payload)]));
}
+84
View File
@@ -0,0 +1,84 @@
import { concatBytes, writeUint32 } from "./bytes.js";
import { hmacSha512 } from "./hash.js";
import { parseExtendedPublicKey } from "./key.js";
import { addPublicKeyTweak } from "./secp256k1.js";
const HARDENED_INDEX = 0x80000000;
/**
* @typedef {import("./key.js").ExtendedPublicKey} ExtendedPublicKey
*/
/**
* @typedef {Object} DerivedPublicKey
* @property {number} index
* @property {Uint8Array} publicKey
*/
/**
* @param {ExtendedPublicKey} key
* @param {number} index
* @returns {Promise<ExtendedPublicKey>}
*/
export async function derivePublicChild(key, index) {
if (!Number.isSafeInteger(index) || index < 0 || index >= HARDENED_INDEX) {
throw new Error("Expected a non-hardened child index");
}
const data = concatBytes([key.publicKey, writeUint32(index)]);
const digest = await hmacSha512(key.chainCode, data);
const tweak = digest.slice(0, 32);
const chainCode = digest.slice(32);
return {
text: key.text,
depth: key.depth + 1,
childNumber: index,
parentFingerprint: key.parentFingerprint,
chainCode,
publicKey: addPublicKeyTweak(key.publicKey, tweak),
version: key.version,
};
}
/**
* @param {ExtendedPublicKey} key
* @param {readonly number[]} path
*/
export async function derivePublicPath(key, path) {
let child = key;
for (const index of path) {
child = await derivePublicChild(child, index);
}
return child;
}
/**
* @param {ExtendedPublicKey} key
* @param {number} start
* @param {number} count
* @param {readonly number[]} [path]
* @returns {Promise<DerivedPublicKey[]>}
*/
export async function derivePublicKeys(key, start, count, path = []) {
const parent = await derivePublicPath(key, path);
const children = /** @type {DerivedPublicKey[]} */ ([]);
for (let offset = 0; offset < count; offset += 1) {
const index = start + offset;
const child = await derivePublicChild(parent, index);
children.push({ index, publicKey: child.publicKey });
}
return children;
}
/**
* @param {string} text
*/
export async function parseXpub(text) {
return parseExtendedPublicKey(text);
}
+92
View File
@@ -0,0 +1,92 @@
/**
* @param {number} length
*/
export function createBytes(length) {
return new Uint8Array(length);
}
/**
* @param {Uint8Array[]} parts
*/
export function concatBytes(parts) {
const length = parts.reduce((total, part) => total + part.length, 0);
const bytes = createBytes(length);
let offset = 0;
for (const part of parts) {
bytes.set(part, offset);
offset += part.length;
}
return bytes;
}
/**
* @param {Uint8Array} bytes
* @param {number} start
*/
export function readUint32(bytes, start) {
return (
bytes[start] * 0x1000000 +
bytes[start + 1] * 0x10000 +
bytes[start + 2] * 0x100 +
bytes[start + 3]
);
}
/**
* @param {number} value
*/
export function writeUint32(value) {
const bytes = createBytes(4);
bytes[0] = value >>> 24;
bytes[1] = value >>> 16;
bytes[2] = value >>> 8;
bytes[3] = value;
return bytes;
}
/**
* @param {Uint8Array} bytes
*/
export function bytesToBigInt(bytes) {
let value = 0n;
for (const byte of bytes) {
value = (value << 8n) + BigInt(byte);
}
return value;
}
/**
* @param {bigint} value
* @param {number} length
*/
export function bigIntToBytes(value, length) {
const bytes = createBytes(length);
let remaining = value;
for (let i = length - 1; i >= 0; i -= 1) {
bytes[i] = Number(remaining & 0xffn);
remaining >>= 8n;
}
return bytes;
}
/**
* @param {Uint8Array} left
* @param {Uint8Array} right
*/
export function bytesEqual(left, right) {
if (left.length !== right.length) return false;
for (let i = 0; i < left.length; i += 1) {
if (left[i] !== right[i]) return false;
}
return true;
}
+401
View File
@@ -0,0 +1,401 @@
import { concatBytes } from "./bytes.js";
import { derivePublicKeys, parseXpub } from "./bip32.js";
import { encodeP2wshAddressData } from "./address.js";
const CHECKSUM_SEPARATOR = "#";
const WSH_SORTEDMULTI_PREFIX = "wsh(sortedmulti(";
const WSH_SORTEDMULTI_SUFFIX = "))";
const OP_CHECKMULTISIG = 0xae;
const COMPRESSED_PUBLIC_KEY_BYTES = 33;
const MAX_WSH_MULTISIG_KEYS = 20;
/**
* @typedef {import("./address.js").BitcoinNetwork} BitcoinNetwork
* @typedef {import("./index.js").GeneratedAddress} GeneratedAddress
*/
/**
* @typedef {Object} DescriptorKey
* @property {string} xpub
* @property {number[]} path
*/
/**
* @typedef {Object} SortedMultisigDescriptor
* @property {"v0_p2wsh_sortedmulti"} script
* @property {number} threshold
* @property {DescriptorKey[]} keys
*/
/**
* @param {string} text
*/
function compactText(text) {
return text.trim().replace(/\s+/g, "");
}
/**
* @param {string} text
*/
function stripDescriptorChecksum(text) {
const value = compactText(text);
const checksumIndex = value.indexOf(CHECKSUM_SEPARATOR);
return checksumIndex === -1 ? value : value.slice(0, checksumIndex);
}
/**
* @param {string} text
*/
function isSupportedDescriptor(text) {
return (
text.startsWith(WSH_SORTEDMULTI_PREFIX) &&
text.endsWith(WSH_SORTEDMULTI_SUFFIX)
);
}
/**
* @param {string} text
*/
export function extractOutputDescriptors(text) {
const value = compactText(text);
const descriptors = /** @type {string[]} */ ([]);
let offset = 0;
while (offset < value.length) {
const start = value.indexOf(WSH_SORTEDMULTI_PREFIX, offset);
if (start === -1) break;
let depth = 0;
let end = -1;
let seenOpen = false;
for (let index = start; index < value.length; index += 1) {
const character = value[index];
if (character === "(") {
depth += 1;
seenOpen = true;
}
if (character === ")") {
depth -= 1;
}
if (seenOpen && depth === 0) {
end = index + 1;
break;
}
}
if (end === -1) break;
const descriptor = stripDescriptorChecksum(value.slice(start, end));
if (isSupportedDescriptor(descriptor)) {
descriptors.push(descriptor);
}
offset = end;
}
return descriptors;
}
/**
* @param {string} text
*/
export function isOutputDescriptor(text) {
return extractOutputDescriptors(text).length > 0;
}
/**
* @param {string} text
*/
function readFirstOutputDescriptor(text) {
const descriptor = extractOutputDescriptors(text)[0];
if (!descriptor) {
throw new Error("Unsupported output descriptor");
}
return descriptor;
}
/**
* @param {string} text
*/
function splitDescriptorArguments(text) {
const values = /** @type {string[]} */ ([]);
let bracketDepth = 0;
let groupDepth = 0;
let start = 0;
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
if (character === "[") bracketDepth += 1;
if (character === "]") bracketDepth -= 1;
if (character === "(") groupDepth += 1;
if (character === ")") groupDepth -= 1;
if (character === "," && bracketDepth === 0 && groupDepth === 0) {
values.push(text.slice(start, index));
start = index + 1;
}
}
values.push(text.slice(start));
return values;
}
/**
* @param {string} value
*/
function readThreshold(value) {
const threshold = Number(value);
if (!Number.isSafeInteger(threshold) || threshold < 1) {
throw new Error("Invalid multisig threshold");
}
return threshold;
}
/**
* @param {string} value
*/
function readNonHardenedIndex(value) {
if (value.endsWith("'") || value.endsWith("h")) {
throw new Error("Descriptor xpub derivation cannot be hardened");
}
const index = Number(value);
if (!Number.isSafeInteger(index) || index < 0) {
throw new Error("Invalid descriptor derivation path");
}
return index;
}
/**
* @param {string} text
*/
function readDescriptorKeyPath(text) {
if (!text.startsWith("/")) {
throw new Error("Expected a ranged descriptor key path");
}
const segments = text.slice(1).split("/");
if (segments[segments.length - 1] !== "*") {
throw new Error("Expected a descriptor wildcard path");
}
return segments.slice(0, -1).map(readNonHardenedIndex);
}
/**
* @param {string} text
* @returns {DescriptorKey}
*/
function readDescriptorKey(text) {
let value = text;
if (value.startsWith("[")) {
const end = value.indexOf("]");
if (end === -1) {
throw new Error("Invalid descriptor key origin");
}
value = value.slice(end + 1);
}
const pathIndex = value.indexOf("/");
if (pathIndex === -1) {
throw new Error("Expected descriptor key derivation");
}
return {
xpub: value.slice(0, pathIndex),
path: readDescriptorKeyPath(value.slice(pathIndex)),
};
}
/**
* @param {string} text
* @returns {SortedMultisigDescriptor}
*/
export function parseOutputDescriptor(text) {
const value = readFirstOutputDescriptor(text);
const body = value.slice(
WSH_SORTEDMULTI_PREFIX.length,
-WSH_SORTEDMULTI_SUFFIX.length,
);
const [thresholdText, ...keyTexts] = splitDescriptorArguments(body);
const threshold = readThreshold(thresholdText);
const keys = keyTexts.map(readDescriptorKey);
if (
threshold > keys.length ||
keys.length < 1 ||
keys.length > MAX_WSH_MULTISIG_KEYS
) {
throw new Error("Invalid multisig key count");
}
return {
script: "v0_p2wsh_sortedmulti",
threshold,
keys,
};
}
/**
* @param {string} descriptorText
*/
function inferDescriptorBranchId(descriptorText) {
const descriptor = parseOutputDescriptor(descriptorText);
const branchIds = descriptor.keys.map((key) => {
return key.path[key.path.length - 1];
});
const sameBranch = branchIds.every((branchId) => {
return branchId === branchIds[0];
});
if (!sameBranch) return undefined;
if (branchIds[0] === 0) return "receive";
if (branchIds[0] === 1) return "change";
}
/**
* @param {string} text
*/
export function getOutputDescriptorBranchIds(text) {
const branchIds = /** @type {string[]} */ ([]);
for (const descriptor of extractOutputDescriptors(text)) {
const branchId = inferDescriptorBranchId(descriptor);
if (branchId && !branchIds.includes(branchId)) {
branchIds.push(branchId);
}
}
return branchIds.length ? branchIds : ["receive"];
}
/**
* @param {string} source
* @param {string} [branchId]
*/
export function selectOutputDescriptor(source, branchId = "receive") {
const descriptors = extractOutputDescriptors(source);
if (descriptors.length === 0) {
throw new Error("Unsupported output descriptor");
}
return descriptors.find((descriptor) => {
return inferDescriptorBranchId(descriptor) === branchId;
}) ?? descriptors[0];
}
/**
* @param {Uint8Array} left
* @param {Uint8Array} right
*/
function compareBytes(left, right) {
for (let index = 0; index < Math.min(left.length, right.length); index += 1) {
if (left[index] !== right[index]) return left[index] - right[index];
}
return left.length - right.length;
}
/**
* @param {number} value
*/
function encodeScriptNumber(value) {
if (value <= 16) return Uint8Array.of(0x50 + value);
return Uint8Array.of(0x01, value);
}
/**
* @param {readonly Uint8Array[]} publicKeys
* @param {number} threshold
*/
function encodeSortedMultisigScript(publicKeys, threshold) {
const sortedKeys = [...publicKeys].sort(compareBytes);
const pushes = sortedKeys.map((publicKey) => {
if (publicKey.length !== COMPRESSED_PUBLIC_KEY_BYTES) {
throw new Error("Expected compressed multisig public keys");
}
return concatBytes([Uint8Array.of(COMPRESSED_PUBLIC_KEY_BYTES), publicKey]);
});
return concatBytes([
encodeScriptNumber(threshold),
...pushes,
encodeScriptNumber(sortedKeys.length),
Uint8Array.of(OP_CHECKMULTISIG),
]);
}
/**
* @param {string} descriptorText
* @param {Object} options
* @param {number} options.start
* @param {number} options.count
* @returns {Promise<GeneratedAddress[]>}
*/
export async function generateAddressesFromDescriptor(descriptorText, options) {
const descriptor = parseOutputDescriptor(descriptorText);
const parsedKeys = await Promise.all(
descriptor.keys.map((key) => parseXpub(key.xpub)),
);
const network = parsedKeys[0].version.network;
const childSets = await Promise.all(
parsedKeys.map((key, index) => {
if (key.version.network !== network) {
throw new Error("Descriptor xpub networks must match");
}
return derivePublicKeys(
key,
options.start,
options.count,
descriptor.keys[index].path,
);
}),
);
const addresses = /** @type {GeneratedAddress[]} */ ([]);
for (let offset = 0; offset < options.count; offset += 1) {
const publicKeys = childSets.map((children) => children[offset].publicKey);
const witnessScript = encodeSortedMultisigScript(
publicKeys,
descriptor.threshold,
);
const addressData = await encodeP2wshAddressData(witnessScript, network);
addresses.push({
index: options.start + offset,
address: addressData.address,
payload: addressData.payload,
script: descriptor.script,
network,
addrType: "v0_p2wsh",
});
}
return addresses;
}
+275
View File
@@ -0,0 +1,275 @@
import { concatBytes, createBytes } from "./bytes.js";
const ripemdLeftIndexes = /** @type {const} */ ([
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
7, 4, 13, 1, 10, 6, 15, 3, 12, 0, 9, 5, 2, 14, 11, 8,
3, 10, 14, 4, 9, 15, 8, 1, 2, 7, 0, 6, 13, 11, 5, 12,
1, 9, 11, 10, 0, 8, 12, 4, 13, 3, 7, 15, 14, 5, 6, 2,
4, 0, 5, 9, 7, 12, 2, 10, 14, 1, 3, 8, 11, 6, 15, 13,
]);
const ripemdRightIndexes = /** @type {const} */ ([
5, 14, 7, 0, 9, 2, 11, 4, 13, 6, 15, 8, 1, 10, 3, 12,
6, 11, 3, 7, 0, 13, 5, 10, 14, 15, 8, 12, 4, 9, 1, 2,
15, 5, 1, 3, 7, 14, 6, 9, 11, 8, 12, 2, 10, 0, 4, 13,
8, 6, 4, 1, 3, 11, 15, 0, 5, 12, 2, 13, 9, 7, 10, 14,
12, 15, 10, 4, 1, 5, 8, 7, 6, 2, 13, 14, 0, 3, 9, 11,
]);
const ripemdLeftShifts = /** @type {const} */ ([
11, 14, 15, 12, 5, 8, 7, 9, 11, 13, 14, 15, 6, 7, 9, 8,
7, 6, 8, 13, 11, 9, 7, 15, 7, 12, 15, 9, 11, 7, 13, 12,
11, 13, 6, 7, 14, 9, 13, 15, 14, 8, 13, 6, 5, 12, 7, 5,
11, 12, 14, 15, 14, 15, 9, 8, 9, 14, 5, 6, 8, 6, 5, 12,
9, 15, 5, 11, 6, 8, 13, 12, 5, 12, 13, 14, 11, 8, 5, 6,
]);
const ripemdRightShifts = /** @type {const} */ ([
8, 9, 9, 11, 13, 15, 15, 5, 7, 7, 8, 11, 14, 14, 12, 6,
9, 13, 15, 7, 12, 8, 9, 11, 7, 7, 12, 7, 6, 15, 13, 11,
9, 7, 15, 11, 8, 6, 6, 14, 12, 13, 5, 14, 13, 13, 7, 5,
15, 5, 8, 11, 14, 14, 6, 14, 6, 9, 12, 9, 12, 5, 15, 8,
8, 5, 12, 9, 12, 5, 14, 6, 8, 13, 6, 5, 15, 13, 11, 11,
]);
const ripemdLeftConstants = /** @type {const} */ ([
0x00000000,
0x5a827999,
0x6ed9eba1,
0x8f1bbcdc,
0xa953fd4e,
]);
const ripemdRightConstants = /** @type {const} */ ([
0x50a28be6,
0x5c4dd124,
0x6d703ef3,
0x7a6d76e9,
0x00000000,
]);
/**
* @param {Uint8Array} bytes
*/
function toArrayBuffer(bytes) {
const buffer = new ArrayBuffer(bytes.length);
new Uint8Array(buffer).set(bytes);
return buffer;
}
/**
* @param {Uint8Array} bytes
*/
export async function sha256(bytes) {
return new Uint8Array(
await crypto.subtle.digest("SHA-256", toArrayBuffer(bytes)),
);
}
/**
* @param {Uint8Array} bytes
*/
export async function doubleSha256(bytes) {
return sha256(await sha256(bytes));
}
/**
* @param {Uint8Array} key
* @param {Uint8Array} bytes
*/
export async function hmacSha512(key, bytes) {
const cryptoKey = await crypto.subtle.importKey(
"raw",
toArrayBuffer(key),
{ name: "HMAC", hash: "SHA-512" },
false,
["sign"],
);
return new Uint8Array(
await crypto.subtle.sign("HMAC", cryptoKey, toArrayBuffer(bytes)),
);
}
/**
* @param {number} value
* @param {number} bits
*/
function rotateLeft(value, bits) {
return (value << bits) | (value >>> (32 - bits));
}
/**
* @param {number} round
* @param {number} x
* @param {number} y
* @param {number} z
*/
function ripemdFunction(round, x, y, z) {
if (round < 16) return x ^ y ^ z;
if (round < 32) return (x & y) | (~x & z);
if (round < 48) return (x | ~y) ^ z;
if (round < 64) return (x & z) | (y & ~z);
return x ^ (y | ~z);
}
/**
* @param {Uint8Array} bytes
*/
function createRipemdBlocks(bytes) {
const bitLength = BigInt(bytes.length) * 8n;
const length = bytes.length + 1 + 8;
const paddedLength = Math.ceil(length / 64) * 64;
const padded = createBytes(paddedLength);
padded.set(bytes);
padded[bytes.length] = 0x80;
for (let i = 0; i < 8; i += 1) {
padded[paddedLength - 8 + i] = Number(
(bitLength >> (BigInt(i) * 8n)) & 0xffn,
);
}
return padded;
}
/**
* @param {Uint8Array} block
* @param {number} offset
*/
function readRipemdWords(block, offset) {
const words = /** @type {number[]} */ ([]);
for (let i = 0; i < 16; i += 1) {
const start = offset + i * 4;
words.push(
block[start] |
(block[start + 1] << 8) |
(block[start + 2] << 16) |
(block[start + 3] << 24),
);
}
return words;
}
/**
* @param {Uint8Array} target
* @param {number} offset
* @param {number} value
*/
function writeRipemdWord(target, offset, value) {
target[offset] = value;
target[offset + 1] = value >>> 8;
target[offset + 2] = value >>> 16;
target[offset + 3] = value >>> 24;
}
/**
* @param {Uint8Array} bytes
*/
export function ripemd160(bytes) {
const blocks = createRipemdBlocks(bytes);
const digest = createBytes(20);
let h0 = 0x67452301;
let h1 = 0xefcdab89;
let h2 = 0x98badcfe;
let h3 = 0x10325476;
let h4 = 0xc3d2e1f0;
for (let offset = 0; offset < blocks.length; offset += 64) {
const words = readRipemdWords(blocks, offset);
let al = h0;
let bl = h1;
let cl = h2;
let dl = h3;
let el = h4;
let ar = h0;
let br = h1;
let cr = h2;
let dr = h3;
let er = h4;
for (let round = 0; round < 80; round += 1) {
const leftGroup = Math.floor(round / 16);
const rightGroup = Math.floor(round / 16);
const nextLeft =
(rotateLeft(
(al +
ripemdFunction(round, bl, cl, dl) +
words[ripemdLeftIndexes[round]] +
ripemdLeftConstants[leftGroup]) |
0,
ripemdLeftShifts[round],
) +
el) |
0;
const nextRight =
(rotateLeft(
(ar +
ripemdFunction(79 - round, br, cr, dr) +
words[ripemdRightIndexes[round]] +
ripemdRightConstants[rightGroup]) |
0,
ripemdRightShifts[round],
) +
er) |
0;
al = el;
el = dl;
dl = rotateLeft(cl, 10);
cl = bl;
bl = nextLeft;
ar = er;
er = dr;
dr = rotateLeft(cr, 10);
cr = br;
br = nextRight;
}
const nextH0 = (h1 + cl + dr) | 0;
h1 = (h2 + dl + er) | 0;
h2 = (h3 + el + ar) | 0;
h3 = (h4 + al + br) | 0;
h4 = (h0 + bl + cr) | 0;
h0 = nextH0;
}
writeRipemdWord(digest, 0, h0);
writeRipemdWord(digest, 4, h1);
writeRipemdWord(digest, 8, h2);
writeRipemdWord(digest, 12, h3);
writeRipemdWord(digest, 16, h4);
return digest;
}
/**
* @param {Uint8Array} bytes
*/
export async function hash160(bytes) {
return ripemd160(await sha256(bytes));
}
/**
* @param {Uint8Array} bytes
*/
export async function checksum(bytes) {
return (await doubleSha256(bytes)).slice(0, 4);
}
/**
* @param {number} version
* @param {Uint8Array} payload
*/
export async function versionedChecksum(version, payload) {
const versioned = concatBytes([Uint8Array.of(version), payload]);
return checksum(versioned);
}
+134
View File
@@ -0,0 +1,134 @@
import { encodePublicKeyAddressData } from "./address.js";
import { derivePublicKeys, parseXpub } from "./bip32.js";
import {
generateAddressesFromDescriptor,
getOutputDescriptorBranchIds,
isOutputDescriptor,
selectOutputDescriptor,
} from "./descriptor.js";
const DEFAULT_START_INDEX = 0;
const DEFAULT_ADDRESS_COUNT = 20;
const MAX_ADDRESS_COUNT = 100;
const addrTypeByScript = /** @type {const} */ ({
p2pkh: "p2pkh",
p2sh_p2wpkh: "p2sh",
v0_p2wpkh: "v0_p2wpkh",
v1_p2tr: "v1_p2tr",
v0_p2wsh_sortedmulti: "v0_p2wsh",
});
/**
* @typedef {import("./address.js").AddressScript} AddressScript
* @typedef {import("./address.js").BitcoinNetwork} BitcoinNetwork
* @typedef {(typeof addrTypeByScript)[keyof typeof addrTypeByScript]} AddressType
*/
/**
* @typedef {Object} GeneratedAddress
* @property {number} index
* @property {string} address
* @property {Uint8Array} payload
* @property {Uint8Array} [publicKey]
* @property {AddressScript} script
* @property {BitcoinNetwork} network
* @property {AddressType} addrType
*/
/**
* @typedef {Object} GenerateAddressesOptions
* @property {number} [start]
* @property {number} [count]
* @property {AddressScript} [script]
* @property {readonly number[]} [path]
* @property {string} [branchId]
*/
/**
* @param {number | undefined} value
*/
function readStart(value) {
if (value === undefined) return DEFAULT_START_INDEX;
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error("Expected a non-negative start index");
}
return value;
}
/**
* @param {number | undefined} value
*/
function readCount(value) {
const count = value ?? DEFAULT_ADDRESS_COUNT;
if (!Number.isSafeInteger(count) || count < 1 || count > MAX_ADDRESS_COUNT) {
throw new Error(`Expected an address count from 1 to ${MAX_ADDRESS_COUNT}`);
}
return count;
}
/**
* @param {string} xpub
* @param {GenerateAddressesOptions} [options]
* @returns {Promise<GeneratedAddress[]>}
*/
export async function generateAddressesFromXpub(xpub, options = {}) {
const key = await parseXpub(xpub);
const start = readStart(options.start);
const count = readCount(options.count);
const script = options.script ?? key.version.script;
const addrType = addrTypeByScript[script];
const children = await derivePublicKeys(key, start, count, options.path);
const addresses = /** @type {GeneratedAddress[]} */ ([]);
for (const child of children) {
const addressData = await encodePublicKeyAddressData(
child.publicKey,
script,
key.version.network,
);
addresses.push({
index: child.index,
address: addressData.address,
payload: addressData.payload,
publicKey: child.publicKey,
script,
network: key.version.network,
addrType,
});
}
return addresses;
}
/**
* @param {string} source
* @param {GenerateAddressesOptions} [options]
* @returns {Promise<GeneratedAddress[]>}
*/
export async function generateAddressesFromWalletSource(source, options = {}) {
const start = readStart(options.start);
const count = readCount(options.count);
if (isOutputDescriptor(source)) {
return generateAddressesFromDescriptor(
selectOutputDescriptor(source, options.branchId),
{ start, count },
);
}
return generateAddressesFromXpub(source, {
...options,
start,
count,
});
}
export {
getOutputDescriptorBranchIds,
isOutputDescriptor,
};
+124
View File
@@ -0,0 +1,124 @@
import { decodeBase58Check } from "./base58.js";
import { readUint32 } from "./bytes.js";
const EXTENDED_PUBLIC_KEY_LENGTH = 78;
const PUBLIC_KEY_LENGTH = 33;
const CHAIN_CODE_LENGTH = 32;
export const extendedPublicKeyVersions = /** @type {const} */ ([
{
version: 0x0488b21e,
prefix: "xpub",
network: "mainnet",
script: "p2pkh",
addrType: "p2pkh",
},
{
version: 0x049d7cb2,
prefix: "ypub",
network: "mainnet",
script: "p2sh_p2wpkh",
addrType: "p2sh",
},
{
version: 0x04b24746,
prefix: "zpub",
network: "mainnet",
script: "v0_p2wpkh",
addrType: "v0_p2wpkh",
},
{
version: 0x043587cf,
prefix: "tpub",
network: "testnet",
script: "p2pkh",
addrType: "p2pkh",
},
{
version: 0x044a5262,
prefix: "upub",
network: "testnet",
script: "p2sh_p2wpkh",
addrType: "p2sh",
},
{
version: 0x045f1cf6,
prefix: "vpub",
network: "testnet",
script: "v0_p2wpkh",
addrType: "v0_p2wpkh",
},
]);
/**
* @typedef {typeof extendedPublicKeyVersions[number]} ExtendedPublicKeyVersion
*/
/**
* @typedef {Object} ExtendedPublicKey
* @property {string} text
* @property {number} depth
* @property {number} childNumber
* @property {Uint8Array} parentFingerprint
* @property {Uint8Array} chainCode
* @property {Uint8Array} publicKey
* @property {ExtendedPublicKeyVersion} version
*/
/**
* @param {number} version
* @returns {ExtendedPublicKeyVersion}
*/
function findExtendedPublicKeyVersion(version) {
const metadata = extendedPublicKeyVersions.find((item) => {
return item.version === version;
});
if (!metadata) {
throw new Error(`Unsupported extended public key version: ${version}`);
}
return metadata;
}
/**
* @param {Uint8Array} publicKey
*/
function validateCompressedPublicKey(publicKey) {
if (
publicKey.length !== PUBLIC_KEY_LENGTH ||
(publicKey[0] !== 0x02 && publicKey[0] !== 0x03)
) {
throw new Error("Expected a compressed public key");
}
}
/**
* @param {string} text
* @returns {Promise<ExtendedPublicKey>}
*/
export async function parseExtendedPublicKey(text) {
const value = text.trim();
const bytes = await decodeBase58Check(value);
if (bytes.length !== EXTENDED_PUBLIC_KEY_LENGTH) {
throw new Error("Invalid extended public key length");
}
const version = findExtendedPublicKeyVersion(readUint32(bytes, 0));
const parentFingerprint = bytes.slice(5, 9);
const chainCode = bytes.slice(13, 13 + CHAIN_CODE_LENGTH);
const publicKey = bytes.slice(45);
validateCompressedPublicKey(publicKey);
return {
text: value,
depth: bytes[4],
childNumber: readUint32(bytes, 9),
parentFingerprint,
chainCode,
publicKey,
version,
};
}
+255
View File
@@ -0,0 +1,255 @@
import { bigIntToBytes, bytesToBigInt, createBytes } from "./bytes.js";
const FIELD_PRIME =
0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn;
const GROUP_ORDER =
0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
const GENERATOR = /** @type {const} */ ({
x: 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n,
y: 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n,
});
/**
* @typedef {Object} Secp256k1Point
* @property {bigint} x
* @property {bigint} y
*/
/**
* @param {bigint} value
* @param {bigint} modulo
*/
function mod(value, modulo) {
const result = value % modulo;
return result >= 0n ? result : result + modulo;
}
/**
* @param {bigint} value
* @param {bigint} exponent
* @param {bigint} modulo
*/
function modPow(value, exponent, modulo) {
let result = 1n;
let base = mod(value, modulo);
let power = exponent;
while (power > 0n) {
if (power & 1n) result = mod(result * base, modulo);
base = mod(base * base, modulo);
power >>= 1n;
}
return result;
}
/**
* @param {bigint} value
*/
function invertField(value) {
let low = mod(value, FIELD_PRIME);
let high = FIELD_PRIME;
let lowCoefficient = 1n;
let highCoefficient = 0n;
while (low > 1n) {
const ratio = high / low;
[low, high] = [high - low * ratio, low];
[lowCoefficient, highCoefficient] = [
highCoefficient - lowCoefficient * ratio,
lowCoefficient,
];
}
return mod(lowCoefficient, FIELD_PRIME);
}
/**
* @param {Secp256k1Point} point
*/
function isOnCurve(point) {
const left = mod(point.y * point.y, FIELD_PRIME);
const right = mod(point.x * point.x * point.x + 7n, FIELD_PRIME);
return left === right;
}
/**
* @param {Secp256k1Point} point
*/
function doublePoint(point) {
if (point.y === 0n) return null;
const slope = mod(
3n * point.x * point.x * invertField(2n * point.y),
FIELD_PRIME,
);
const x = mod(slope * slope - 2n * point.x, FIELD_PRIME);
const y = mod(slope * (point.x - x) - point.y, FIELD_PRIME);
return { x, y };
}
/**
* @param {Secp256k1Point} point
*/
function negatePoint(point) {
return { x: point.x, y: mod(-point.y, FIELD_PRIME) };
}
/**
* @param {Secp256k1Point} point
*/
function forceEvenY(point) {
return point.y & 1n ? negatePoint(point) : point;
}
/**
* @param {Secp256k1Point | null} left
* @param {Secp256k1Point | null} right
* @returns {Secp256k1Point | null}
*/
function addPoints(left, right) {
if (!left) return right;
if (!right) return left;
if (left.x === right.x) {
if (mod(left.y + right.y, FIELD_PRIME) === 0n) return null;
return doublePoint(left);
}
const slope = mod(
(right.y - left.y) * invertField(right.x - left.x),
FIELD_PRIME,
);
const x = mod(slope * slope - left.x - right.x, FIELD_PRIME);
const y = mod(slope * (left.x - x) - left.y, FIELD_PRIME);
return { x, y };
}
/**
* @param {bigint} scalar
* @param {Secp256k1Point} point
* @returns {Secp256k1Point | null}
*/
function multiplyPoint(scalar, point) {
let result = /** @type {Secp256k1Point | null} */ (null);
let addend = /** @type {Secp256k1Point | null} */ (point);
let remaining = scalar;
while (remaining > 0n) {
if (remaining & 1n) result = addPoints(result, addend);
addend = addPoints(addend, addend);
remaining >>= 1n;
}
return result;
}
/**
* @param {bigint} x
* @param {boolean} odd
*/
function liftX(x, odd) {
if (x >= FIELD_PRIME) {
throw new Error("Invalid secp256k1 x coordinate");
}
let y = modPow(x * x * x + 7n, (FIELD_PRIME + 1n) / 4n, FIELD_PRIME);
if (Boolean(y & 1n) !== odd) {
y = FIELD_PRIME - y;
}
const point = { x, y };
if (!isOnCurve(point)) {
throw new Error("Invalid secp256k1 point");
}
return point;
}
/**
* @param {Uint8Array} publicKey
*/
export function parseCompressedPublicKey(publicKey) {
if (
publicKey.length !== 33 ||
(publicKey[0] !== 0x02 && publicKey[0] !== 0x03)
) {
throw new Error("Expected a compressed public key");
}
return liftX(bytesToBigInt(publicKey.slice(1)), publicKey[0] === 0x03);
}
/**
* @param {Secp256k1Point} point
*/
export function compressPublicKey(point) {
const publicKey = createBytes(33);
publicKey[0] = point.y & 1n ? 0x03 : 0x02;
publicKey.set(bigIntToBytes(point.x, 32), 1);
return publicKey;
}
/**
* @param {Uint8Array} publicKey
*/
export function getXOnlyPublicKey(publicKey) {
const point = forceEvenY(parseCompressedPublicKey(publicKey));
return bigIntToBytes(point.x, 32);
}
/**
* @param {Uint8Array} publicKey
* @param {Uint8Array} tweak
*/
export function addPublicKeyTweak(publicKey, tweak) {
const scalar = bytesToBigInt(tweak);
if (scalar === 0n || scalar >= GROUP_ORDER) {
throw new Error("Invalid secp256k1 public key tweak");
}
const tweakPoint = multiplyPoint(scalar, GENERATOR);
const childPoint = addPoints(parseCompressedPublicKey(publicKey), tweakPoint);
if (!childPoint) {
throw new Error("Invalid secp256k1 child public key");
}
return compressPublicKey(childPoint);
}
/**
* @param {Uint8Array} publicKey
* @param {Uint8Array} tweak
*/
export function addXOnlyPublicKeyTweak(publicKey, tweak) {
const scalar = bytesToBigInt(tweak);
if (scalar >= GROUP_ORDER) {
throw new Error("Invalid secp256k1 x-only public key tweak");
}
const internalPoint = forceEvenY(parseCompressedPublicKey(publicKey));
const tweakPoint = scalar === 0n ? null : multiplyPoint(scalar, GENERATOR);
const outputPoint = addPoints(internalPoint, tweakPoint);
if (!outputPoint) {
throw new Error("Invalid secp256k1 x-only child public key");
}
return bigIntToBytes(outputPoint.x, 32);
}