diff --git a/packages/wasm-utxo/Cargo.toml b/packages/wasm-utxo/Cargo.toml index 6c58a15..9d6e570 100644 --- a/packages/wasm-utxo/Cargo.toml +++ b/packages/wasm-utxo/Cargo.toml @@ -6,6 +6,9 @@ edition = "2021" [lib] crate-type = ["cdylib"] +[lints.clippy] +all = "warn" + [dependencies] wasm-bindgen = "0.2" js-sys = "0.3" diff --git a/packages/wasm-utxo/README.md b/packages/wasm-utxo/README.md index 1293f57..6c68e77 100644 --- a/packages/wasm-utxo/README.md +++ b/packages/wasm-utxo/README.md @@ -9,12 +9,12 @@ that help verify and co-sign transactions built by the BitGo Wallet Platform API This project is under active development. -| Feature | Bitcoin | BitcoinCash | BitcoinGold | Dash | Doge | Litecoin | Zcash | -| --------------------------------------- | -------------- | ----------- | ----------- | ------- | ------- | -------- | ------- | -| Descriptor Wallet: Address Support | ✅ Complete | 🚫 | 🚫 | 🚫 | 🚫 | 🚫 | 🚫 | -| Descriptor Wallet: Transaction Support | ✅ Complete | 🚫 | 🚫 | 🚫 | 🚫 | 🚫 | 🚫 | -| FixedScript Wallet: Address Generation | 🏗️ In Progress | ⏳ TODO | ⏳ TODO | ⏳ TODO | ⏳ TODO | ⏳ TODO | ⏳ TODO | -| FixedScript Wallet: Transaction Support | ⏳ TODO | ⏳ TODO | ⏳ TODO | ⏳ TODO | ⏳ TODO | ⏳ TODO | ⏳ TODO | +| Feature | Bitcoin | BitcoinCash | BitcoinGold | Dash | Doge | Litecoin | Zcash | +| --------------------------------------- | ----------- | ----------- | ----------- | ----------- | ----------- | ----------- | ----------- | +| Descriptor Wallet: Address Support | ✅ Complete | 🚫 | 🚫 | 🚫 | 🚫 | 🚫 | 🚫 | +| Descriptor Wallet: Transaction Support | ✅ Complete | 🚫 | 🚫 | 🚫 | 🚫 | 🚫 | 🚫 | +| FixedScript Wallet: Address Generation | ✅ Complete | ✅ Complete | ✅ Complete | ✅ Complete | ✅ Complete | ✅ Complete | ✅ Complete | +| FixedScript Wallet: Transaction Support | ⏳ TODO | ⏳ TODO | ⏳ TODO | ⏳ TODO | ⏳ TODO | ⏳ TODO | ⏳ TODO | ## Building diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index dc3a670..c369367 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -8,10 +8,36 @@ export type DescriptorPkType = "derivable" | "definite" | "string"; export type ScriptContext = "tap" | "segwitv0" | "legacy"; +export type AddressFormat = "default" | "cashaddr"; + export type SignPsbtResult = { [inputIndex: number]: [pubkey: string][]; }; +// BitGo coin names (from Network::from_coin_name in src/networks.rs) +export type CoinName = + | "btc" + | "tbtc" + | "tbtc4" + | "tbtcsig" + | "tbtcbgsig" + | "bch" + | "tbch" + | "bcha" + | "tbcha" + | "btg" + | "tbtg" + | "bsv" + | "tbsv" + | "dash" + | "tdash" + | "doge" + | "tdoge" + | "ltc" + | "tltc" + | "zec" + | "tzec"; + declare module "./wasm/wasm_utxo" { interface WrapDescriptor { /** These are not the same types of nodes as in the ast module */ @@ -37,6 +63,23 @@ declare module "./wasm/wasm_utxo" { signWithXprv(this: WrapPsbt, xprv: string): SignPsbtResult; signWithPrv(this: WrapPsbt, prv: Uint8Array): SignPsbtResult; } + + interface Address { + /** + * Convert output script to address string + * @param script - The output script as a byte array + * @param network - The utxolib Network object from JavaScript + * @param format - Optional address format: "default" or "cashaddr" (only applicable for Bitcoin Cash and eCash) + */ + fromOutputScript(script: Uint8Array, network: any, format?: AddressFormat): string; + /** + * Convert address string to output script + * @param address - The address string + * @param network - The utxolib Network object from JavaScript + * @param format - Optional address format (currently unused for decoding as all formats are accepted) + */ + toOutputScript(address: string, network: any, format?: AddressFormat): Uint8Array; + } } import { Address as WasmAddress } from "./wasm/wasm_utxo"; @@ -44,9 +87,22 @@ import { Address as WasmAddress } from "./wasm/wasm_utxo"; export { WrapDescriptor as Descriptor } from "./wasm/wasm_utxo"; export { WrapMiniscript as Miniscript } from "./wasm/wasm_utxo"; export { WrapPsbt as Psbt } from "./wasm/wasm_utxo"; +export { FixedScriptWallet } from "./wasm/wasm_utxo"; export namespace utxolibCompat { export const Address = WasmAddress; } +export function toOutputScriptWithCoin(address: string, coin: CoinName): Uint8Array { + return wasm.toOutputScriptWithCoin(address, coin); +} + +export function fromOutputScriptWithCoin( + script: Uint8Array, + coin: CoinName, + format?: AddressFormat, +): string { + return wasm.fromOutputScriptWithCoin(script, coin, format); +} + export * as ast from "./ast"; diff --git a/packages/wasm-utxo/src/address/cashaddr.rs b/packages/wasm-utxo/src/address/cashaddr.rs index 5afa541..1a1d23a 100644 --- a/packages/wasm-utxo/src/address/cashaddr.rs +++ b/packages/wasm-utxo/src/address/cashaddr.rs @@ -1,8 +1,8 @@ //! Cashaddr encoding/decoding module for Bitcoin Cash and eCash. //! //! Implements the cashaddr checksum algorithm as defined in: -//! - Spec: https://github.com/bitcoincashorg/bitcoincash.org/blob/master/spec/cashaddr.md -//! - Reference implementation: https://github.com/Bitcoin-ABC/bitcoin-abc/blob/master/src/cashaddr.cpp +//! - Spec: +//! - Reference implementation: //! //! This implementation directly follows the official specification and passes all test vectors. //! diff --git a/packages/wasm-utxo/src/address/mod.rs b/packages/wasm-utxo/src/address/mod.rs index 824ea1e..661795e 100644 --- a/packages/wasm-utxo/src/address/mod.rs +++ b/packages/wasm-utxo/src/address/mod.rs @@ -35,11 +35,16 @@ mod base58check; mod bech32; pub mod cashaddr; +pub mod networks; pub mod utxolib_compat; pub use base58check::Base58CheckCodec; pub use bech32::Bech32Codec; pub use cashaddr::CashAddrCodec; +pub use networks::{ + from_output_script_with_coin, from_output_script_with_network, to_output_script_with_coin, + to_output_script_with_network, +}; use crate::bitcoin::{Script, ScriptBuf}; use std::fmt; @@ -172,11 +177,6 @@ pub fn from_output_script(script: &Script, codec: &dyn AddressCodec) -> Result Result { - codec.decode(address) -} - /// Try multiple codecs to decode an address pub fn to_output_script_try_codecs( address: &str, @@ -200,6 +200,11 @@ mod tests { use crate::bitcoin::hashes::Hash; use crate::bitcoin::PubkeyHash; + /// Convert address string to output script (convenience wrapper) + pub fn to_output_script(address: &str, codec: &dyn AddressCodec) -> Result { + codec.decode(address) + } + #[test] fn test_base58_roundtrip() { let hash = hex::decode("1e231c7f9b3415daaa53ee5a7e12e120f00ec212").unwrap(); diff --git a/packages/wasm-utxo/src/address/networks.rs b/packages/wasm-utxo/src/address/networks.rs new file mode 100644 index 0000000..b8b8e63 --- /dev/null +++ b/packages/wasm-utxo/src/address/networks.rs @@ -0,0 +1,480 @@ +//! Network-aware address encoding and decoding. +//! +//! This module bridges the Network enum with address codecs, providing +//! convenient functions to encode/decode addresses using network identifiers. + +use super::{ + from_output_script, to_output_script_try_codecs, AddressCodec, AddressError, Result, ScriptBuf, + BITCOIN, BITCOIN_BECH32, BITCOIN_CASH, BITCOIN_CASH_CASHADDR, BITCOIN_CASH_TESTNET, + BITCOIN_CASH_TESTNET_CASHADDR, BITCOIN_GOLD, BITCOIN_GOLD_BECH32, BITCOIN_GOLD_TESTNET, + BITCOIN_GOLD_TESTNET_BECH32, BITCOIN_SV, BITCOIN_SV_TESTNET, DASH, DASH_TEST, DOGECOIN, + DOGECOIN_TEST, ECASH, ECASH_CASHADDR, ECASH_TEST, ECASH_TEST_CASHADDR, LITECOIN, + LITECOIN_BECH32, LITECOIN_TEST, LITECOIN_TEST_BECH32, TESTNET, TESTNET_BECH32, ZCASH, + ZCASH_TEST, +}; +use crate::bitcoin::Script; +use crate::networks::Network; + +/// Get codecs for decoding addresses for a given network. +/// Returns multiple codecs to try in order (Base58Check, Bech32, CashAddr, etc.) +fn get_decode_codecs(network: Network) -> Vec<&'static dyn AddressCodec> { + match network { + Network::Bitcoin => vec![&BITCOIN, &BITCOIN_BECH32], + Network::BitcoinTestnet3 + | Network::BitcoinTestnet4 + | Network::BitcoinPublicSignet + | Network::BitcoinBitGoSignet => { + vec![&TESTNET, &TESTNET_BECH32] + } + Network::BitcoinCash => vec![&BITCOIN_CASH, &BITCOIN_CASH_CASHADDR], + Network::BitcoinCashTestnet => vec![&BITCOIN_CASH_TESTNET, &BITCOIN_CASH_TESTNET_CASHADDR], + Network::Ecash => vec![&ECASH, &ECASH_CASHADDR], + Network::EcashTestnet => vec![&ECASH_TEST, &ECASH_TEST_CASHADDR], + Network::BitcoinGold => vec![&BITCOIN_GOLD, &BITCOIN_GOLD_BECH32], + Network::BitcoinGoldTestnet => vec![&BITCOIN_GOLD_TESTNET, &BITCOIN_GOLD_TESTNET_BECH32], + Network::BitcoinSV => vec![&BITCOIN_SV], + Network::BitcoinSVTestnet => vec![&BITCOIN_SV_TESTNET], + Network::Dash => vec![&DASH], + Network::DashTestnet => vec![&DASH_TEST], + Network::Dogecoin => vec![&DOGECOIN], + Network::DogecoinTestnet => vec![&DOGECOIN_TEST], + Network::Litecoin => vec![&LITECOIN, &LITECOIN_BECH32], + Network::LitecoinTestnet => vec![&LITECOIN_TEST, &LITECOIN_TEST_BECH32], + Network::Zcash => vec![&ZCASH], + Network::ZcashTestnet => vec![&ZCASH_TEST], + } +} + +/// Address encoding format selection +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AddressFormat { + /// Use default address encoding + /// In most cases, there is one unambiguous address encoding for a given network and script type. + /// For Bitcoin Cash, Base58Check is the default. + Default, + /// For Bitcoin Cash and eCash, there is a choice of address formats: base58check or cashaddr. + Cashaddr, +} + +impl AddressFormat { + /// Parse an AddressFormat from an optional string. + /// Returns Default if None or if the string is empty. + pub fn from_optional_str(s: Option<&str>) -> Result { + match s { + None | Some("") | Some("default") => Ok(Self::Default), + Some("cashaddr") => Ok(Self::Cashaddr), + Some(other) => Err(AddressError::InvalidAddress(format!( + "Unknown address format: {}. Valid formats are: 'default', 'cashaddr'", + other + ))), + } + } +} + +/// Get codec for encoding an address for a given network and script type. +fn get_encode_codec( + network: Network, + script: &Script, + format: AddressFormat, +) -> Result<&'static dyn AddressCodec> { + let is_witness = script.is_p2wpkh() || script.is_p2wsh() || script.is_p2tr(); + let is_legacy = script.is_p2pkh() || script.is_p2sh(); + + if !is_witness && !is_legacy { + return Err(AddressError::UnsupportedScriptType( + "Script is not a standard address type (P2PKH, P2SH, P2WPKH, P2WSH, P2TR)".to_string(), + )); + } + + // Handle Cashaddr format request + if matches!(format, AddressFormat::Cashaddr) { + return match network { + Network::BitcoinCash => Ok(&BITCOIN_CASH_CASHADDR), + Network::BitcoinCashTestnet => Ok(&BITCOIN_CASH_TESTNET_CASHADDR), + Network::Ecash => Ok(&ECASH_CASHADDR), + Network::EcashTestnet => Ok(&ECASH_TEST_CASHADDR), + _ => Err(AddressError::UnsupportedScriptType( + format!("Cashaddr format is only supported for Bitcoin Cash and eCash networks, not for {:?}", network), + )), + }; + } + + match network { + Network::Bitcoin => { + if is_witness { + Ok(&BITCOIN_BECH32) + } else { + Ok(&BITCOIN) + } + } + Network::BitcoinTestnet3 + | Network::BitcoinTestnet4 + | Network::BitcoinPublicSignet + | Network::BitcoinBitGoSignet => { + if is_witness { + Ok(&TESTNET_BECH32) + } else { + Ok(&TESTNET) + } + } + Network::BitcoinCash => Ok(&BITCOIN_CASH), + Network::BitcoinCashTestnet => Ok(&BITCOIN_CASH_TESTNET), + Network::Ecash => Ok(&ECASH), + Network::EcashTestnet => Ok(&ECASH_TEST), + Network::BitcoinGold => { + if is_witness { + Ok(&BITCOIN_GOLD_BECH32) + } else { + Ok(&BITCOIN_GOLD) + } + } + Network::BitcoinGoldTestnet => { + if is_witness { + Ok(&BITCOIN_GOLD_TESTNET_BECH32) + } else { + Ok(&BITCOIN_GOLD_TESTNET) + } + } + Network::BitcoinSV => Ok(&BITCOIN_SV), + Network::BitcoinSVTestnet => Ok(&BITCOIN_SV_TESTNET), + Network::Dash => Ok(&DASH), + Network::DashTestnet => Ok(&DASH_TEST), + Network::Dogecoin => Ok(&DOGECOIN), + Network::DogecoinTestnet => Ok(&DOGECOIN_TEST), + Network::Litecoin => { + if is_witness { + Ok(&LITECOIN_BECH32) + } else { + Ok(&LITECOIN) + } + } + Network::LitecoinTestnet => { + if is_witness { + Ok(&LITECOIN_TEST_BECH32) + } else { + Ok(&LITECOIN_TEST) + } + } + Network::Zcash => Ok(&ZCASH), + Network::ZcashTestnet => Ok(&ZCASH_TEST), + } +} + +/// Convert an address string to an output script using a Network. +/// Tries multiple address formats for the given network (Base58, Bech32, CashAddr, etc.) +pub fn to_output_script_with_network(address: &str, network: Network) -> Result { + let codecs = get_decode_codecs(network); + to_output_script_try_codecs(address, &codecs) +} + +/// Convert an output script to an address string using a Network. +/// Automatically selects the appropriate format based on the script type. +pub fn from_output_script_with_network(script: &Script, network: Network) -> Result { + from_output_script_with_network_and_format(script, network, AddressFormat::Default) +} + +/// Convert an output script to an address string using a Network and format. +pub fn from_output_script_with_network_and_format( + script: &Script, + network: Network, + format: AddressFormat, +) -> Result { + let codec = get_encode_codec(network, script, format)?; + from_output_script(script, codec) +} + +/// Convert an address string to an output script using a BitGo coin name. +/// The coin name is first converted to a Network using `Network::from_coin_name()`. +pub fn to_output_script_with_coin(address: &str, coin: &str) -> Result { + let network = Network::from_coin_name(coin) + .ok_or_else(|| AddressError::InvalidAddress(format!("Unknown coin: {}", coin)))?; + to_output_script_with_network(address, network) +} + +/// Convert an output script to an address string using a BitGo coin name. +/// The coin name is first converted to a Network using `Network::from_coin_name()`. +pub fn from_output_script_with_coin(script: &Script, coin: &str) -> Result { + from_output_script_with_coin_and_format(script, coin, AddressFormat::Default) +} + +/// Convert an output script to an address string using a BitGo coin name and format. +pub fn from_output_script_with_coin_and_format( + script: &Script, + coin: &str, + format: AddressFormat, +) -> Result { + let network = Network::from_coin_name(coin) + .ok_or_else(|| AddressError::InvalidAddress(format!("Unknown coin: {}", coin)))?; + from_output_script_with_network_and_format(script, network, format) +} + +// WASM bindings +use wasm_bindgen::prelude::*; + +/// WASM binding: Convert an address string to an output script using a BitGo coin name. +#[wasm_bindgen(js_name = toOutputScriptWithCoin)] +pub fn to_output_script_with_coin_js( + address: &str, + coin: &str, +) -> std::result::Result, JsValue> { + to_output_script_with_coin(address, coin) + .map(|script| script.to_bytes()) + .map_err(|e| JsValue::from_str(&e.to_string())) +} + +/// WASM binding: Convert an output script to an address string using a BitGo coin name. +/// +/// # Arguments +/// * `script` - The output script bytes +/// * `coin` - The BitGo coin name (e.g., "btc", "bch", "ecash") +/// * `format` - Optional address format: "default" or "cashaddr" (only applicable for Bitcoin Cash and eCash) +#[wasm_bindgen(js_name = fromOutputScriptWithCoin)] +pub fn from_output_script_with_coin_js( + script: &[u8], + coin: &str, + format: Option, +) -> std::result::Result { + let script_obj = Script::from_bytes(script); + let format_str = format.as_deref(); + let address_format = AddressFormat::from_optional_str(format_str) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + + from_output_script_with_coin_and_format(script_obj, coin, address_format) + .map_err(|e| JsValue::from_str(&e.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bitcoin::hashes::Hash; + use crate::bitcoin::{PubkeyHash, ScriptBuf}; + + #[test] + fn test_to_output_script_with_network() { + // Bitcoin mainnet P2PKH + let addr = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"; + let script = to_output_script_with_network(addr, Network::Bitcoin).unwrap(); + assert!(script.is_p2pkh()); + + // Bitcoin mainnet bech32 + let addr = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4"; + let script = to_output_script_with_network(addr, Network::Bitcoin).unwrap(); + assert!(script.is_p2wpkh()); + + // Bitcoin testnet + let addr = "mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn"; + let script = to_output_script_with_network(addr, Network::BitcoinTestnet3).unwrap(); + assert!(script.is_p2pkh()); + } + + #[test] + fn test_from_output_script_with_network() { + // Create a P2PKH script + let hash = hex::decode("62e907b15cbf27d5425399ebf6f0fb50ebb88f18").unwrap(); + let pubkey_hash = PubkeyHash::from_byte_array(hash.try_into().unwrap()); + let script = ScriptBuf::new_p2pkh(&pubkey_hash); + + // Encode for Bitcoin mainnet + let addr = from_output_script_with_network(&script, Network::Bitcoin).unwrap(); + assert_eq!(addr, "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"); + + // Encode for Bitcoin testnet + let addr = from_output_script_with_network(&script, Network::BitcoinTestnet3).unwrap(); + assert_eq!(addr, "mpXwg4jMtRhuSpVq4xS3HFHmCmWp9NyGKt"); + } + + #[test] + fn test_to_output_script_with_coin() { + // BTC + let addr = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"; + let script = to_output_script_with_coin(addr, "btc").unwrap(); + assert!(script.is_p2pkh()); + + // tbtc + let addr = "mipcBbFg9gMiCh81Kj8tqqdgoZub1ZJRfn"; + let script = to_output_script_with_coin(addr, "tbtc").unwrap(); + assert!(script.is_p2pkh()); + } + + #[test] + fn test_from_output_script_with_coin() { + let hash = hex::decode("62e907b15cbf27d5425399ebf6f0fb50ebb88f18").unwrap(); + let pubkey_hash = PubkeyHash::from_byte_array(hash.try_into().unwrap()); + let script = ScriptBuf::new_p2pkh(&pubkey_hash); + + // btc + let addr = from_output_script_with_coin(&script, "btc").unwrap(); + assert_eq!(addr, "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"); + + // tbtc + let addr = from_output_script_with_coin(&script, "tbtc").unwrap(); + assert_eq!(addr, "mpXwg4jMtRhuSpVq4xS3HFHmCmWp9NyGKt"); + } + + #[test] + fn test_invalid_coin() { + let addr = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"; + let result = to_output_script_with_coin(addr, "invalid_coin"); + assert!(result.is_err()); + } + + #[test] + fn test_base58_bitcoin_cash() { + // Bitcoin Cash should prefer base58 format for encoding + let hash = hex::decode("F5BF48B397DAE70BE82B3CCA4793F8EB2B6CDAC9").unwrap(); + let pubkey_hash = PubkeyHash::from_byte_array(hash.try_into().unwrap()); + let script = ScriptBuf::new_p2pkh(&pubkey_hash); + + let addr = from_output_script_with_network(&script, Network::BitcoinCash).unwrap(); + assert_eq!(addr, "1PQPheJQSauxRPTxzNMUco1XmoCyPoEJCp"); + + // Should be able to decode it back + let decoded = to_output_script_with_network(&addr, Network::BitcoinCash).unwrap(); + assert_eq!(script, decoded); + } + + #[test] + fn test_witness_addresses() { + // Create a P2WPKH script + let hash = hex::decode("751e76e8199196d454941c45d1b3a323f1433bd6").unwrap(); + let wpkh = crate::bitcoin::WPubkeyHash::from_byte_array(hash.try_into().unwrap()); + let script = ScriptBuf::new_p2wpkh(&wpkh); + + // Should encode to bech32 for Bitcoin + let addr = from_output_script_with_network(&script, Network::Bitcoin).unwrap(); + assert!(addr.starts_with("bc1")); + + // Should encode to bech32 for Litecoin + let addr = from_output_script_with_network(&script, Network::Litecoin).unwrap(); + assert!(addr.starts_with("ltc1")); + } + + #[test] + fn test_cashaddr_format() { + // Test that Cashaddr format works for Bitcoin Cash + let hash = hex::decode("F5BF48B397DAE70BE82B3CCA4793F8EB2B6CDAC9").unwrap(); + let pubkey_hash = PubkeyHash::from_byte_array(hash.try_into().unwrap()); + let script = ScriptBuf::new_p2pkh(&pubkey_hash); + + // Cashaddr format for Bitcoin Cash mainnet + let codec = + get_encode_codec(Network::BitcoinCash, &script, AddressFormat::Cashaddr).unwrap(); + let addr = from_output_script(&script, codec).unwrap(); + assert!(addr.starts_with("bitcoincash:")); + + // Cashaddr format for Bitcoin Cash testnet + let codec = get_encode_codec( + Network::BitcoinCashTestnet, + &script, + AddressFormat::Cashaddr, + ) + .unwrap(); + let addr = from_output_script(&script, codec).unwrap(); + assert!( + addr.starts_with("bchtest:") || addr.starts_with("bitcoincash:"), + "Expected bchtest: or bitcoincash: prefix but got: {}", + addr + ); + + // Cashaddr format for eCash mainnet + let codec = get_encode_codec(Network::Ecash, &script, AddressFormat::Cashaddr).unwrap(); + let addr = from_output_script(&script, codec).unwrap(); + assert!(addr.starts_with("ecash:")); + + // Cashaddr format for eCash testnet + let codec = + get_encode_codec(Network::EcashTestnet, &script, AddressFormat::Cashaddr).unwrap(); + let addr = from_output_script(&script, codec).unwrap(); + assert!( + addr.starts_with("ectest:") || addr.starts_with("ecash:"), + "Expected ectest: or ecash: prefix but got: {}", + addr + ); + } + + #[test] + fn test_cashaddr_format_error_for_non_bch_ecash() { + // Test that Cashaddr format returns error for non-BCH/eCash networks + let hash = hex::decode("62e907b15cbf27d5425399ebf6f0fb50ebb88f18").unwrap(); + let pubkey_hash = PubkeyHash::from_byte_array(hash.try_into().unwrap()); + let script = ScriptBuf::new_p2pkh(&pubkey_hash); + + // Should error for Bitcoin + let result = get_encode_codec(Network::Bitcoin, &script, AddressFormat::Cashaddr); + assert!(result.is_err()); + if let Err(e) = result { + assert!(e.to_string().contains("Cashaddr format is only supported")); + } + + // Should error for Litecoin + let result = get_encode_codec(Network::Litecoin, &script, AddressFormat::Cashaddr); + assert!(result.is_err()); + + // Should error for Dogecoin + let result = get_encode_codec(Network::Dogecoin, &script, AddressFormat::Cashaddr); + assert!(result.is_err()); + } + + #[test] + fn test_from_output_script_with_coin_and_format() { + // Test with Bitcoin Cash using default format (base58) + let hash = hex::decode("F5BF48B397DAE70BE82B3CCA4793F8EB2B6CDAC9").unwrap(); + let pubkey_hash = PubkeyHash::from_byte_array(hash.try_into().unwrap()); + let script = ScriptBuf::new_p2pkh(&pubkey_hash); + + // Default format for bch should be base58 + let addr = from_output_script_with_coin_and_format(&script, "bch", AddressFormat::Default) + .unwrap(); + assert_eq!(addr, "1PQPheJQSauxRPTxzNMUco1XmoCyPoEJCp"); + + // Cashaddr format for bch should be cashaddr + let addr = from_output_script_with_coin_and_format(&script, "bch", AddressFormat::Cashaddr) + .unwrap(); + assert!(addr.starts_with("bitcoincash:")); + + // Default format for tbch should be base58 + let addr = from_output_script_with_coin_and_format(&script, "tbch", AddressFormat::Default) + .unwrap(); + assert!(!addr.starts_with("bchtest:")); + + // Cashaddr format for tbch should be cashaddr + let addr = + from_output_script_with_coin_and_format(&script, "tbch", AddressFormat::Cashaddr) + .unwrap(); + assert!(addr.starts_with("bchtest:") || addr.starts_with("bitcoincash:")); + + // Cashaddr format should error for non-BCH/eCash coins + let result = + from_output_script_with_coin_and_format(&script, "btc", AddressFormat::Cashaddr); + assert!(result.is_err()); + } + + #[test] + fn test_address_format_from_optional_str() { + // Test valid formats + assert!(matches!( + AddressFormat::from_optional_str(None), + Ok(AddressFormat::Default) + )); + assert!(matches!( + AddressFormat::from_optional_str(Some("")), + Ok(AddressFormat::Default) + )); + assert!(matches!( + AddressFormat::from_optional_str(Some("default")), + Ok(AddressFormat::Default) + )); + assert!(matches!( + AddressFormat::from_optional_str(Some("cashaddr")), + Ok(AddressFormat::Cashaddr) + )); + + // Test invalid format + let result = AddressFormat::from_optional_str(Some("invalid")); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Unknown address format")); + } +} diff --git a/packages/wasm-utxo/src/address/utxolib_compat.rs b/packages/wasm-utxo/src/address/utxolib_compat.rs index eb5e64f..5b5e623 100644 --- a/packages/wasm-utxo/src/address/utxolib_compat.rs +++ b/packages/wasm-utxo/src/address/utxolib_compat.rs @@ -3,6 +3,7 @@ /// but for now we need to keep this compatibility layer. use wasm_bindgen::JsValue; +use crate::address::networks::AddressFormat; use crate::address::{bech32, cashaddr, Base58CheckCodec}; use crate::bitcoin::{Script, ScriptBuf}; @@ -33,10 +34,29 @@ impl Network { } /// Convert output script to address string using a utxolib Network object -pub fn from_output_script_with_network(script: &Script, network: &Network) -> Result { - // Determine script type and choose appropriate codec - // Note: We always use base58check for P2PKH/P2SH to match utxolib behavior, - // even if cashAddr is available. Cashaddr is only used for decoding. +pub fn from_output_script_with_network( + script: &Script, + network: &Network, + format: AddressFormat, +) -> Result { + // Handle cashaddr format if requested + if matches!(format, AddressFormat::Cashaddr) { + if let Some(ref cash_addr) = network.cash_addr { + if script.is_p2pkh() { + let hash = &script.as_bytes()[3..23]; + return cashaddr::encode_cashaddr(hash, false, &cash_addr.prefix); + } else if script.is_p2sh() { + let hash = &script.as_bytes()[2..22]; + return cashaddr::encode_cashaddr(hash, true, &cash_addr.prefix); + } + } else { + return Err(AddressError::UnsupportedScriptType( + "Cashaddr format is only supported for Bitcoin Cash and eCash networks".to_string(), + )); + } + } + + // Default format: use base58check for P2PKH/P2SH if script.is_p2pkh() || script.is_p2sh() { let codec = Base58CheckCodec::new(network.pub_key_hash, network.script_hash); use crate::address::AddressCodec; @@ -114,17 +134,23 @@ impl Address { /// # Arguments /// * `script` - The output script as a byte array /// * `network` - The utxolib Network object from JavaScript + /// * `format` - Optional address format: "default" or "cashaddr" (only applicable for Bitcoin Cash and eCash) #[wasm_bindgen(js_name = fromOutputScript)] pub fn from_output_script_js( script: &[u8], network: JsValue, + format: Option, ) -> std::result::Result { let network = Network::from_js_value(&network).map_err(|e| JsValue::from_str(&e.to_string()))?; let script_obj = Script::from_bytes(script); - from_output_script_with_network(script_obj, &network) + let format_str = format.as_deref(); + let address_format = AddressFormat::from_optional_str(format_str) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + + from_output_script_with_network(script_obj, &network, address_format) .map_err(|e| JsValue::from_str(&e.to_string())) } @@ -133,14 +159,23 @@ impl Address { /// # Arguments /// * `address` - The address string /// * `network` - The utxolib Network object from JavaScript + /// * `format` - Optional address format (currently unused for decoding as all formats are accepted) #[wasm_bindgen(js_name = toOutputScript)] pub fn to_output_script_js( address: &str, network: JsValue, + format: Option, ) -> std::result::Result, JsValue> { let network = Network::from_js_value(&network).map_err(|e| JsValue::from_str(&e.to_string()))?; + // Validate format parameter even though we don't use it for decoding + if let Some(fmt) = format { + let format_str = Some(fmt.as_str()); + AddressFormat::from_optional_str(format_str) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + } + to_output_script_with_network(address, &network) .map(|script| script.to_bytes()) .map_err(|e| JsValue::from_str(&e.to_string())) diff --git a/packages/wasm-utxo/src/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/mod.rs index 0c1c665..4118f97 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/mod.rs @@ -9,3 +9,51 @@ pub mod test_utils; pub use wallet_keys::*; pub use wallet_scripts::*; +use wasm_bindgen::prelude::*; + +use crate::address::networks::AddressFormat; +use crate::error::WasmMiniscriptError; +use crate::try_from_js_value::TryFromJsValue; +use crate::utxolib_compat::Network; + +#[wasm_bindgen] +pub struct FixedScriptWallet; + +#[wasm_bindgen] +impl FixedScriptWallet { + #[wasm_bindgen(js_name = outputScript)] + pub fn output_script( + keys: JsValue, + chain: u32, + index: u32, + ) -> Result, WasmMiniscriptError> { + let chain = Chain::try_from(chain) + .map_err(|e| WasmMiniscriptError::new(&format!("Invalid chain: {}", e)))?; + + let xpubs = xpub_triple_from_jsvalue(&keys)?; + let scripts = WalletScripts::from_xpubs(&xpubs, chain, index); + Ok(scripts.output_script().to_bytes()) + } + + #[wasm_bindgen(js_name = address)] + pub fn address( + keys: JsValue, + chain: u32, + index: u32, + network: JsValue, + ) -> Result { + let network = Network::try_from_js_value(&network)?; + let xpubs = xpub_triple_from_jsvalue(&keys)?; + let chain = Chain::try_from(chain) + .map_err(|e| WasmMiniscriptError::new(&format!("Invalid chain: {}", e)))?; + let scripts = WalletScripts::from_xpubs(&xpubs, chain, index); + let script = scripts.output_script(); + let address = crate::address::utxolib_compat::from_output_script_with_network( + &script, + &network, + AddressFormat::Default, + ) + .map_err(|e| WasmMiniscriptError::new(&format!("Failed to generate address: {}", e)))?; + Ok(address.to_string()) + } +} diff --git a/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs b/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs index 813e44c..0be632e 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs @@ -1,11 +1,51 @@ use std::convert::TryInto; +use std::str::FromStr; use crate::bitcoin::{bip32::Xpub, CompressedPublicKey}; +use crate::error::WasmMiniscriptError; +use wasm_bindgen::JsValue; pub type XpubTriple = [Xpub; 3]; pub type PubTriple = [CompressedPublicKey; 3]; +pub fn xpub_triple_from_jsvalue(keys: &JsValue) -> Result { + let keys_array = js_sys::Array::from(keys); + if keys_array.length() != 3 { + return Err(WasmMiniscriptError::new("Expected exactly 3 xpub keys")); + } + + let key_strings: Result<[String; 3], _> = (0..3) + .map(|i| { + keys_array.get(i).as_string().ok_or_else(|| { + WasmMiniscriptError::new(&format!("Key at index {} is not a string", i)) + }) + }) + .collect::, _>>() + .and_then(|v| { + v.try_into() + .map_err(|_| WasmMiniscriptError::new("Failed to convert to array")) + }); + + xpub_triple_from_strings(&key_strings?) +} + +pub fn xpub_triple_from_strings( + xpub_strings: &[String; 3], +) -> Result { + let xpubs: Result, _> = xpub_strings + .iter() + .map(|s| { + Xpub::from_str(s) + .map_err(|e| WasmMiniscriptError::new(&format!("Failed to parse xpub: {}", e))) + }) + .collect(); + + xpubs? + .try_into() + .map_err(|_| WasmMiniscriptError::new("Expected exactly 3 xpubs")) +} + pub fn to_pub_triple(xpubs: &XpubTriple) -> PubTriple { xpubs .iter() diff --git a/packages/wasm-utxo/src/lib.rs b/packages/wasm-utxo/src/lib.rs index 420309a..3baa882 100644 --- a/packages/wasm-utxo/src/lib.rs +++ b/packages/wasm-utxo/src/lib.rs @@ -3,6 +3,7 @@ mod descriptor; mod error; mod fixed_script_wallet; mod miniscript; +mod networks; mod psbt; mod try_from_js_value; mod try_into_js_value; @@ -11,10 +12,14 @@ mod try_into_js_value; // this package is transitioning to a all-purpose bitcoin package, so we want easy access pub use ::miniscript::bitcoin; -pub use address::utxolib_compat; -pub use address::*; +pub use address::{ + from_output_script_with_coin, from_output_script_with_network, to_output_script_with_coin, + to_output_script_with_network, utxolib_compat, +}; + pub use descriptor::WrapDescriptor; pub use miniscript::WrapMiniscript; +pub use networks::Network; pub use psbt::WrapPsbt; pub use crate::fixed_script_wallet::*; diff --git a/packages/wasm-utxo/src/networks.rs b/packages/wasm-utxo/src/networks.rs new file mode 100644 index 0000000..059c47c --- /dev/null +++ b/packages/wasm-utxo/src/networks.rs @@ -0,0 +1,462 @@ +//! Definitions of various bitcoin-like networks +// Inspired by https://github.com/BitGo/BitGoJS/blob/master/modules/utxo-lib/src/networks.ts but +// with a few naming improvements. +use std::fmt; +use std::str::FromStr; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Network { + // https://github.com/bitcoin/bitcoin/blob/master/src/validation.cpp + // https://github.com/bitcoin/bitcoin/blob/master/src/chainparams.cpp + Bitcoin, + BitcoinTestnet3, + BitcoinTestnet4, + BitcoinPublicSignet, + BitcoinBitGoSignet, + + // https://github.com/bitcoin-cash-node/bitcoin-cash-node/blob/master/src/validation.cpp + // https://github.com/bitcoin-cash-node/bitcoin-cash-node/blob/master/src/chainparams.cpp + BitcoinCash, + BitcoinCashTestnet, + + // https://github.com/Bitcoin-ABC/bitcoin-abc/blob/master/src/validation.cpp + // https://github.com/Bitcoin-ABC/bitcoin-abc/blob/master/src/chainparams.cpp + Ecash, + EcashTestnet, + + // https://github.com/BTCGPU/BTCGPU/blob/master/src/validation.cpp + // https://github.com/BTCGPU/BTCGPU/blob/master/src/chainparams.cpp + BitcoinGold, + BitcoinGoldTestnet, + + // https://github.com/bitcoin-sv/bitcoin-sv/blob/master/src/validation.cpp + // https://github.com/bitcoin-sv/bitcoin-sv/blob/master/src/chainparams.cpp + BitcoinSV, + BitcoinSVTestnet, + + // https://github.com/dashpay/dash/blob/master/src/validation.cpp + // https://github.com/dashpay/dash/blob/master/src/chainparams.cpp + Dash, + DashTestnet, + + // https://github.com/dogecoin/dogecoin/blob/master/src/validation.cpp + // https://github.com/dogecoin/dogecoin/blob/master/src/chainparams.cpp + Dogecoin, + DogecoinTestnet, + + // https://github.com/litecoin-project/litecoin/blob/master/src/validation.cpp + // https://github.com/litecoin-project/litecoin/blob/master/src/chainparams.cpp + Litecoin, + LitecoinTestnet, + + // https://github.com/zcash/zcash/blob/master/src/validation.cpp + // https://github.com/zcash/zcash/blob/master/src/chainparams.cpp + Zcash, + ZcashTestnet, +} + +impl Network { + /// Array containing all network variants + pub const ALL: &'static [Network] = &[ + Network::Bitcoin, + Network::BitcoinTestnet3, + Network::BitcoinTestnet4, + Network::BitcoinPublicSignet, + Network::BitcoinBitGoSignet, + Network::BitcoinCash, + Network::BitcoinCashTestnet, + Network::Ecash, + Network::EcashTestnet, + Network::BitcoinGold, + Network::BitcoinGoldTestnet, + Network::BitcoinSV, + Network::BitcoinSVTestnet, + Network::Dash, + Network::DashTestnet, + Network::Dogecoin, + Network::DogecoinTestnet, + Network::Litecoin, + Network::LitecoinTestnet, + Network::Zcash, + Network::ZcashTestnet, + ]; + + /// Returns the canonical string name of this network + pub fn as_str(&self) -> &'static str { + match self { + Network::Bitcoin => "Bitcoin", + Network::BitcoinTestnet3 => "BitcoinTestnet3", + Network::BitcoinTestnet4 => "BitcoinTestnet4", + Network::BitcoinPublicSignet => "BitcoinPublicSignet", + Network::BitcoinBitGoSignet => "BitcoinBitGoSignet", + Network::BitcoinCash => "BitcoinCash", + Network::BitcoinCashTestnet => "BitcoinCashTestnet", + Network::Ecash => "Ecash", + Network::EcashTestnet => "EcashTestnet", + Network::BitcoinGold => "BitcoinGold", + Network::BitcoinGoldTestnet => "BitcoinGoldTestnet", + Network::BitcoinSV => "BitcoinSV", + Network::BitcoinSVTestnet => "BitcoinSVTestnet", + Network::Dash => "Dash", + Network::DashTestnet => "DashTestnet", + Network::Dogecoin => "Dogecoin", + Network::DogecoinTestnet => "DogecoinTestnet", + Network::Litecoin => "Litecoin", + Network::LitecoinTestnet => "LitecoinTestnet", + Network::Zcash => "Zcash", + Network::ZcashTestnet => "ZcashTestnet", + } + } + + pub fn from_name_exact(name: &str) -> Option { + match name { + "Bitcoin" => Some(Network::Bitcoin), + "BitcoinTestnet3" => Some(Network::BitcoinTestnet3), + "BitcoinTestnet4" => Some(Network::BitcoinTestnet4), + "BitcoinPublicSignet" => Some(Network::BitcoinPublicSignet), + "BitcoinBitGoSignet" => Some(Network::BitcoinBitGoSignet), + + "BitcoinCash" => Some(Network::BitcoinCash), + "BitcoinCashTestnet" => Some(Network::BitcoinCashTestnet), + + "Ecash" => Some(Network::Ecash), + "EcashTestnet" => Some(Network::EcashTestnet), + + "BitcoinGold" => Some(Network::BitcoinGold), + "BitcoinGoldTestnet" => Some(Network::BitcoinGoldTestnet), + + "BitcoinSV" => Some(Network::BitcoinSV), + "BitcoinSVTestnet" => Some(Network::BitcoinSVTestnet), + + "Dash" => Some(Network::Dash), + "DashTestnet" => Some(Network::DashTestnet), + + "Dogecoin" => Some(Network::Dogecoin), + "DogecoinTestnet" => Some(Network::DogecoinTestnet), + + "Litecoin" => Some(Network::Litecoin), + "LitecoinTestnet" => Some(Network::LitecoinTestnet), + + "Zcash" => Some(Network::Zcash), + "ZcashTestnet" => Some(Network::ZcashTestnet), + + _ => None, + } + } + + /// Convert a network name from @bitgo/utxo-lib to a Network enum value. + pub fn from_utxolib_name(name: &str) -> Option { + // Using table from + // https://github.com/BitGo/BitGoJS/blob/%40bitgo/utxo-lib%4011.13.0/modules/utxo-lib/src/networks.ts + match name { + "bitcoin" => Some(Network::Bitcoin), + "testnet" => Some(Network::BitcoinTestnet3), + "bitcoinPublicSignet" => Some(Network::BitcoinPublicSignet), + "bitcoinTestnet4" => Some(Network::BitcoinTestnet4), + "bitcoinBitGoSignet" => Some(Network::BitcoinBitGoSignet), + "bitcoincash" => Some(Network::BitcoinCash), + "bitcoincashTestnet" => Some(Network::BitcoinCashTestnet), + "ecash" => Some(Network::Ecash), + "ecashTest" => Some(Network::EcashTestnet), + "bitcoingold" => Some(Network::BitcoinGold), + "bitcoingoldTestnet" => Some(Network::BitcoinGoldTestnet), + "bitcoinsv" => Some(Network::BitcoinSV), + "bitcoinsvTestnet" => Some(Network::BitcoinSVTestnet), + "dash" => Some(Network::Dash), + "dashTest" => Some(Network::DashTestnet), + "dogecoin" => Some(Network::Dogecoin), + "dogecoinTest" => Some(Network::DogecoinTestnet), + "litecoin" => Some(Network::Litecoin), + "litecoinTest" => Some(Network::LitecoinTestnet), + "zcash" => Some(Network::Zcash), + "zcashTest" => Some(Network::ZcashTestnet), + _ => None, + } + } + + /// Convert from a bitgo coin name to a Network enum value. + pub fn from_coin_name(name: &str) -> Option { + match name { + "btc" => Some(Network::Bitcoin), + "tbtc" => Some(Network::BitcoinTestnet3), + "tbtc4" => Some(Network::BitcoinTestnet4), + "tbtcsig" => Some(Network::BitcoinPublicSignet), + "tbtcbgsig" => Some(Network::BitcoinBitGoSignet), + "bch" => Some(Network::BitcoinCash), + "tbch" => Some(Network::BitcoinCashTestnet), + "bcha" => Some(Network::Ecash), + "tbcha" => Some(Network::EcashTestnet), + "btg" => Some(Network::BitcoinGold), + "tbtg" => Some(Network::BitcoinGoldTestnet), + "bsv" => Some(Network::BitcoinSV), + "tbsv" => Some(Network::BitcoinSVTestnet), + "dash" => Some(Network::Dash), + "tdash" => Some(Network::DashTestnet), + "doge" => Some(Network::Dogecoin), + "tdoge" => Some(Network::DogecoinTestnet), + "ltc" => Some(Network::Litecoin), + "tltc" => Some(Network::LitecoinTestnet), + "zec" => Some(Network::Zcash), + "tzec" => Some(Network::ZcashTestnet), + _ => None, + } + } + + /// Convert to a BitGo coin name. + pub fn to_coin_name(&self) -> &'static str { + match self { + Network::Bitcoin => "btc", + Network::BitcoinTestnet3 => "tbtc", + Network::BitcoinTestnet4 => "tbtc4", + Network::BitcoinPublicSignet => "tbtcsig", + Network::BitcoinBitGoSignet => "tbtcbgsig", + Network::BitcoinCash => "bch", + Network::BitcoinCashTestnet => "tbch", + Network::Ecash => "bcha", + Network::EcashTestnet => "tbcha", + Network::BitcoinGold => "btg", + Network::BitcoinGoldTestnet => "tbtg", + Network::BitcoinSV => "bsv", + Network::BitcoinSVTestnet => "tbsv", + Network::Dash => "dash", + Network::DashTestnet => "tdash", + Network::Dogecoin => "doge", + Network::DogecoinTestnet => "tdoge", + Network::Litecoin => "ltc", + Network::LitecoinTestnet => "tltc", + Network::Zcash => "zec", + Network::ZcashTestnet => "tzec", + } + } + + pub fn mainnet(self) -> Network { + match self { + Network::Bitcoin + | Network::BitcoinTestnet3 + | Network::BitcoinTestnet4 + | Network::BitcoinPublicSignet + | Network::BitcoinBitGoSignet => Network::Bitcoin, + + Network::BitcoinCash => Network::BitcoinCash, + Network::BitcoinCashTestnet => Network::BitcoinCash, + + Network::Ecash => Network::Ecash, + Network::EcashTestnet => Network::Ecash, + + Network::BitcoinGold => Network::BitcoinGold, + Network::BitcoinGoldTestnet => Network::BitcoinGold, + + Network::BitcoinSV => Network::BitcoinSV, + Network::BitcoinSVTestnet => Network::BitcoinSV, + + Network::Dash => Network::Dash, + Network::DashTestnet => Network::Dash, + + Network::Dogecoin => Network::Dogecoin, + Network::DogecoinTestnet => Network::Dogecoin, + + Network::Litecoin => Network::Litecoin, + Network::LitecoinTestnet => Network::Litecoin, + + Network::Zcash => Network::Zcash, + Network::ZcashTestnet => Network::Zcash, + } + } + + pub fn is_mainnet(self) -> bool { + self == self.mainnet() + } + + pub fn is_testnet(self) -> bool { + !self.is_mainnet() + } +} + +impl fmt::Display for Network { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl FromStr for Network { + type Err = String; + + fn from_str(s: &str) -> Result { + Network::from_name_exact(s).ok_or_else(|| format!("Unknown network: {}", s)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_utxolib_name_all() { + let names = vec![ + ("bitcoin", Network::Bitcoin), + ("testnet", Network::BitcoinTestnet3), + ("bitcoinPublicSignet", Network::BitcoinPublicSignet), + ("bitcoinTestnet4", Network::BitcoinTestnet4), + ("bitcoinBitGoSignet", Network::BitcoinBitGoSignet), + ("bitcoincash", Network::BitcoinCash), + ("bitcoincashTestnet", Network::BitcoinCashTestnet), + ("ecash", Network::Ecash), + ("ecashTest", Network::EcashTestnet), + ("bitcoingold", Network::BitcoinGold), + ("bitcoingoldTestnet", Network::BitcoinGoldTestnet), + ("bitcoinsv", Network::BitcoinSV), + ("bitcoinsvTestnet", Network::BitcoinSVTestnet), + ("dash", Network::Dash), + ("dashTest", Network::DashTestnet), + ("dogecoin", Network::Dogecoin), + ("dogecoinTest", Network::DogecoinTestnet), + ("litecoin", Network::Litecoin), + ("litecoinTest", Network::LitecoinTestnet), + ("zcash", Network::Zcash), + ("zcashTest", Network::ZcashTestnet), + ]; + + for (name, network) in names { + assert_eq!( + Network::from_utxolib_name(name), + Some(network), + "Failed for name: {}", + name + ); + } + } + + #[test] + fn test_all_networks() { + // Verify ALL contains all networks + assert_eq!(Network::ALL.len(), 21); + + // Verify no duplicates + for (i, network1) in Network::ALL.iter().enumerate() { + for (j, network2) in Network::ALL.iter().enumerate() { + if i != j { + assert_ne!(network1, network2); + } + } + } + } + + #[test] + fn test_display() { + assert_eq!(Network::Bitcoin.to_string(), "Bitcoin"); + assert_eq!(Network::BitcoinTestnet3.to_string(), "BitcoinTestnet3"); + assert_eq!(Network::BitcoinCash.to_string(), "BitcoinCash"); + assert_eq!(Network::Ecash.to_string(), "Ecash"); + assert_eq!(Network::Litecoin.to_string(), "Litecoin"); + } + + #[test] + fn test_from_str() { + assert_eq!("Bitcoin".parse::().unwrap(), Network::Bitcoin); + assert_eq!( + "BitcoinTestnet3".parse::().unwrap(), + Network::BitcoinTestnet3 + ); + assert_eq!( + "BitcoinCash".parse::().unwrap(), + Network::BitcoinCash + ); + assert_eq!("Ecash".parse::().unwrap(), Network::Ecash); + + // Test invalid network + assert!("InvalidNetwork".parse::().is_err()); + } + + #[test] + fn test_roundtrip_all_networks() { + // Test that all networks can be converted to string and back + for &network in Network::ALL { + let string = network.to_string(); + let parsed = string.parse::().unwrap(); + assert_eq!(network, parsed, "Round-trip failed for {}", string); + } + } + + #[test] + fn test_roundtrip_as_str() { + // Test that as_str() matches to_string() and round-trips correctly + for &network in Network::ALL { + let as_str = network.as_str(); + let to_string = network.to_string(); + assert_eq!( + as_str, to_string, + "as_str and to_string mismatch for {:?}", + network + ); + + let parsed = Network::from_name_exact(as_str).unwrap(); + assert_eq!( + network, parsed, + "Round-trip via as_str failed for {}", + as_str + ); + } + } + + #[test] + fn test_mainnet_mapping() { + // Test that mainnet() correctly maps testnets to mainnets + assert_eq!(Network::Bitcoin.mainnet(), Network::Bitcoin); + assert_eq!(Network::BitcoinTestnet3.mainnet(), Network::Bitcoin); + assert_eq!(Network::BitcoinTestnet4.mainnet(), Network::Bitcoin); + assert_eq!(Network::BitcoinPublicSignet.mainnet(), Network::Bitcoin); + assert_eq!(Network::BitcoinBitGoSignet.mainnet(), Network::Bitcoin); + + assert_eq!(Network::BitcoinCash.mainnet(), Network::BitcoinCash); + assert_eq!(Network::BitcoinCashTestnet.mainnet(), Network::BitcoinCash); + + assert_eq!(Network::Litecoin.mainnet(), Network::Litecoin); + assert_eq!(Network::LitecoinTestnet.mainnet(), Network::Litecoin); + } + + #[test] + fn test_is_mainnet() { + assert!(Network::Bitcoin.is_mainnet()); + assert!(!Network::BitcoinTestnet3.is_mainnet()); + assert!(!Network::BitcoinTestnet4.is_mainnet()); + assert!(Network::BitcoinCash.is_mainnet()); + assert!(!Network::BitcoinCashTestnet.is_mainnet()); + assert!(Network::Litecoin.is_mainnet()); + assert!(!Network::LitecoinTestnet.is_mainnet()); + } + + #[test] + fn test_is_testnet() { + assert!(!Network::Bitcoin.is_testnet()); + assert!(Network::BitcoinTestnet3.is_testnet()); + assert!(Network::BitcoinTestnet4.is_testnet()); + assert!(!Network::BitcoinCash.is_testnet()); + assert!(Network::BitcoinCashTestnet.is_testnet()); + assert!(!Network::Litecoin.is_testnet()); + assert!(Network::LitecoinTestnet.is_testnet()); + } + + #[test] + fn test_coin_name_round_trip() { + // Test that all networks can be converted to coin name and back + for &network in Network::ALL { + let coin_name = network.to_coin_name(); + let parsed = Network::from_coin_name(coin_name).unwrap(); + assert_eq!( + network, parsed, + "Round-trip failed for {:?} (coin_name: {})", + network, coin_name + ); + } + } + + #[test] + fn test_to_coin_name() { + assert_eq!(Network::Bitcoin.to_coin_name(), "btc"); + assert_eq!(Network::BitcoinTestnet3.to_coin_name(), "tbtc"); + assert_eq!(Network::BitcoinTestnet4.to_coin_name(), "tbtc4"); + assert_eq!(Network::BitcoinCash.to_coin_name(), "bch"); + assert_eq!(Network::Litecoin.to_coin_name(), "ltc"); + assert_eq!(Network::Zcash.to_coin_name(), "zec"); + } +} diff --git a/packages/wasm-utxo/test/address/utxolibCompat.ts b/packages/wasm-utxo/test/address/utxolibCompat.ts index d51676b..776fbf3 100644 --- a/packages/wasm-utxo/test/address/utxolibCompat.ts +++ b/packages/wasm-utxo/test/address/utxolibCompat.ts @@ -3,40 +3,173 @@ import * as fs from "node:fs/promises"; import * as utxolib from "@bitgo/utxo-lib"; import assert from "node:assert"; -import { utxolibCompat } from "../../js"; +import { + utxolibCompat, + FixedScriptWallet, + toOutputScriptWithCoin, + fromOutputScriptWithCoin, + type CoinName, + AddressFormat, +} from "../../js"; + +type Triple = [T, T, T]; type Fixture = [type: string, script: string, address: string]; -async function getFixtures(name: string): Promise { +function getAddressUtxoLib( + keys: Triple, + chain: number, + index: number, + network: utxolib.Network, +): string { + if (!utxolib.bitgo.isChainCode(chain)) { + throw new Error(`Invalid chain code: ${chain}`); + } + + const walletKeys = new utxolib.bitgo.RootWalletKeys(keys); + const derived = walletKeys.deriveForChainAndIndex(chain, index); + const script = utxolib.bitgo.outputScripts.createOutputScript2of3( + derived.publicKeys, + utxolib.bitgo.outputScripts.scriptTypeForChain(chain), + ); + const address = utxolib.address.fromOutputScript(script.scriptPubKey, network); + return address; +} + +function getAddressWasm( + keys: Triple, + chain: number, + index: number, + network: utxolib.Network, +): string { + const xpubs = keys.map((key) => key.neutered().toBase58()); + const wasmAddress = FixedScriptWallet.address(xpubs, chain, index, network); + return wasmAddress; +} + +function getCoinNameForNetwork(name: string): CoinName { + switch (name) { + case "bitcoin": + return "btc"; + case "testnet": + return "tbtc"; + case "bitcoinTestnet4": + return "tbtc4"; + case "bitcoinPublicSignet": + return "tbtcsig"; + case "bitcoinBitGoSignet": + return "tbtcbgsig"; + case "bitcoincash": + return "bch"; + case "bitcoincashTestnet": + return "tbch"; + case "ecash": + return "bcha"; + case "ecashTest": + return "tbcha"; + case "bitcoingold": + return "btg"; + case "bitcoingoldTestnet": + return "tbtg"; + case "bitcoinsv": + return "bsv"; + case "bitcoinsvTestnet": + return "tbsv"; + case "dash": + return "dash"; + case "dashTest": + return "tdash"; + case "dogecoin": + return "doge"; + case "dogecoinTest": + return "tdoge"; + case "litecoin": + return "ltc"; + case "litecoinTest": + return "tltc"; + case "zcash": + return "zec"; + case "zcashTest": + return "tzec"; + default: + throw new Error(`Unknown network: ${name}`); + } +} + +async function getFixtures(name: string, addressFormat?: AddressFormat): Promise { if (name === "bitcoinBitGoSignet") { name = "bitcoinPublicSignet"; } - const fixturePath = path.join(__dirname, "..", "fixtures", "address", `${name}.json`); + const filename = addressFormat ? `${name}-${addressFormat}` : name; + const fixturePath = path.join(__dirname, "..", "fixtures", "address", `${filename}.json`); const fixtures = await fs.readFile(fixturePath, "utf8"); return JSON.parse(fixtures); } -function runTest(network: utxolib.Network) { +function runTest(network: utxolib.Network, addressFormat?: AddressFormat) { const name = utxolib.getNetworkName(network); - describe(`utxolibCompat ${name}`, function () { - let fixtures; + + describe(`utxolibCompat ${name} ${addressFormat ?? "default"}`, function () { + let fixtures: Fixture[]; before(async function () { - fixtures = await getFixtures(name); + fixtures = await getFixtures(name, addressFormat); }); it("should convert to utxolib compatible network", async function () { for (const fixture of fixtures) { const [_type, script, addressRef] = fixture; const scriptBuf = Buffer.from(script, "hex"); - const address = utxolibCompat.Address.fromOutputScript(scriptBuf, network); + const address = utxolibCompat.Address.fromOutputScript(scriptBuf, network, addressFormat); assert.strictEqual(address, addressRef); - const scriptFromAddress = utxolibCompat.Address.toOutputScript(address, network); + const scriptFromAddress = utxolibCompat.Address.toOutputScript( + address, + network, + addressFormat, + ); assert.deepStrictEqual(Buffer.from(scriptFromAddress), scriptBuf); } }); + + it("should convert using coin name", async function () { + const coinName = getCoinNameForNetwork(name); + + for (const fixture of fixtures) { + const [_type, script, addressRef] = fixture; + const scriptBuf = Buffer.from(script, "hex"); + + // Test encoding (script -> address) + const address = fromOutputScriptWithCoin(scriptBuf, coinName, addressFormat); + assert.strictEqual(address, addressRef); + + // Test decoding (address -> script) + const scriptFromAddress = toOutputScriptWithCoin(addressRef, coinName); + assert.deepStrictEqual(Buffer.from(scriptFromAddress), scriptBuf); + } + }); + + const keyTriple = utxolib.testutil.getKeyTriple("wasm"); + + const supportedChainCodes = utxolib.bitgo.chainCodes.filter((chainCode) => { + const scriptType = utxolib.bitgo.outputScripts.scriptTypeForChain(chainCode); + return utxolib.bitgo.outputScripts.isSupportedScriptType(network, scriptType); + }); + + it(`can recreate address from wallet keys for chain codes ${supportedChainCodes.join(", ")}`, function () { + for (const chainCode of supportedChainCodes) { + for (let index = 0; index < 2; index++) { + const utxolibAddress = getAddressUtxoLib(keyTriple, chainCode, index, network); + const wasmAddress = getAddressWasm(keyTriple, chainCode, index, network); + assert.strictEqual(utxolibAddress, wasmAddress); + } + } + }); }); } utxolib.getNetworkList().forEach((network) => { runTest(network); + const mainnet = utxolib.getMainnet(network); + if (mainnet === utxolib.networks.bitcoincash || mainnet === utxolib.networks.ecash) { + runTest(network, "cashaddr"); + } });