mirror of
https://github.com/bitcoinresearchkit/brk.git
synced 2026-06-16 17:59:45 -07:00
global: private xpub support part 1
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mod activity;
|
||||
mod hash_prefix;
|
||||
mod mempool;
|
||||
mod resolve;
|
||||
mod stats;
|
||||
mod txs;
|
||||
mod utxos;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Symlink
+1
@@ -0,0 +1 @@
|
||||
../modules
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 */
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext", "WebWorker"],
|
||||
"skipLibCheck": true,
|
||||
},
|
||||
"exclude": ["assets", "./scripts/modules"],
|
||||
"exclude": ["assets", "modules", "./scripts/modules"],
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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],
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* @param {unknown} error
|
||||
*/
|
||||
export function getErrorMessage(error) {
|
||||
return error instanceof Error ? error.message : "Request failed";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)]));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user