From 579e4a54cdd8be251ac947d7ca936a021776460f Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 27 Oct 2025 15:32:10 +0100 Subject: [PATCH 1/5] feat(wasm-utxo): add clippy lints configuration to Cargo.toml Issue: BTC-2652 Co-authored-by: llm-git --- packages/wasm-utxo/Cargo.toml | 3 +++ 1 file changed, 3 insertions(+) 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" From 3236c5349afe84f997672a19c6fc39bcc089b81d Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 27 Oct 2025 12:50:26 +0100 Subject: [PATCH 2/5] feat(wasm-utxo): expose FixedScriptWallet to API Add FixedScriptWallet with methods for creating output scripts and addresses based on wallet keys. This allows working with standard wallet script types without needing to create descriptors first. Issue: BTC-2652 Co-authored-by: llm-git --- packages/wasm-utxo/js/index.ts | 1 + .../wasm-utxo/src/fixed_script_wallet/mod.rs | 40 ++++++++++++++ .../src/fixed_script_wallet/wallet_keys.rs | 40 ++++++++++++++ .../wasm-utxo/test/address/utxolibCompat.ts | 52 ++++++++++++++++++- 4 files changed, 132 insertions(+), 1 deletion(-) diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index dc3a670..6421ba7 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -44,6 +44,7 @@ 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; diff --git a/packages/wasm-utxo/src/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/mod.rs index 0c1c665..fc6d8b8 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/mod.rs @@ -9,3 +9,43 @@ pub mod test_utils; pub use wallet_keys::*; pub use wallet_scripts::*; +use wasm_bindgen::prelude::*; + +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, + ) + .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/test/address/utxolibCompat.ts b/packages/wasm-utxo/test/address/utxolibCompat.ts index d51676b..8412057 100644 --- a/packages/wasm-utxo/test/address/utxolibCompat.ts +++ b/packages/wasm-utxo/test/address/utxolibCompat.ts @@ -3,10 +3,43 @@ 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 } from "../../js"; + +type Triple = [T, T, T]; type Fixture = [type: string, script: string, address: string]; +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; +} + async function getFixtures(name: string): Promise { if (name === "bitcoinBitGoSignet") { name = "bitcoinPublicSignet"; @@ -34,6 +67,23 @@ function runTest(network: utxolib.Network) { 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); + } + } + }); }); } From e6eae6912f00e07611d07ce794feab74db7a94ee Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Fri, 24 Oct 2025 15:10:56 +0200 Subject: [PATCH 3/5] feat(wasm-utxo): add UTXO network definition module Add comprehensive Network enum for Bitcoin-like networks with conversion methods between different naming systems. Implements utility functions for network identification and conversion between network types, including mainnet/testnet detection and BitGo coin name mapping. Co-authored-by: llm-git --- packages/wasm-utxo/src/networks.rs | 462 +++++++++++++++++++++++++++++ 1 file changed, 462 insertions(+) create mode 100644 packages/wasm-utxo/src/networks.rs 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"); + } +} From cd110dd33ded055e5275539e03e486643e134dae Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Fri, 24 Oct 2025 15:11:11 +0200 Subject: [PATCH 4/5] feat(wasm-utxo): add direct coin-based address encoding/decoding Add APIs for encoding/decoding addresses directly with coin names without requiring a utxolib Network object. This simplifies address handling by eliminating the dependency on JavaScript Network objects. New features: - Add `fromOutputScriptWithCoin` and `toOutputScriptWithCoin` functions - Support all BitGo UTXO coins through a CoinName type - Add cashaddr format option for Bitcoin Cash and eCash networks Issue: BTC-2652 Co-authored-by: llm-git --- packages/wasm-utxo/js/index.ts | 55 ++ packages/wasm-utxo/src/address/cashaddr.rs | 4 +- packages/wasm-utxo/src/address/mod.rs | 15 +- packages/wasm-utxo/src/address/networks.rs | 480 ++++++++++++++++++ .../wasm-utxo/src/address/utxolib_compat.rs | 45 +- .../wasm-utxo/src/fixed_script_wallet/mod.rs | 12 +- packages/wasm-utxo/src/lib.rs | 9 +- .../wasm-utxo/test/address/utxolibCompat.ts | 101 +++- 8 files changed, 696 insertions(+), 25 deletions(-) create mode 100644 packages/wasm-utxo/src/address/networks.rs diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index 6421ba7..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"; @@ -50,4 +93,16 @@ 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 fc6d8b8..4118f97 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/mod.rs @@ -11,6 +11,7 @@ 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; @@ -35,7 +36,12 @@ impl FixedScriptWallet { } #[wasm_bindgen(js_name = address)] - pub fn address(keys: JsValue, chain: u32, index: u32, network: JsValue) -> Result { + 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) @@ -43,7 +49,9 @@ impl FixedScriptWallet { 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, + &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/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/test/address/utxolibCompat.ts b/packages/wasm-utxo/test/address/utxolibCompat.ts index 8412057..776fbf3 100644 --- a/packages/wasm-utxo/test/address/utxolibCompat.ts +++ b/packages/wasm-utxo/test/address/utxolibCompat.ts @@ -3,7 +3,14 @@ import * as fs from "node:fs/promises"; import * as utxolib from "@bitgo/utxo-lib"; import assert from "node:assert"; -import { utxolibCompat, FixedScriptWallet } from "../../js"; +import { + utxolibCompat, + FixedScriptWallet, + toOutputScriptWithCoin, + fromOutputScriptWithCoin, + type CoinName, + AddressFormat, +} from "../../js"; type Triple = [T, T, T]; @@ -40,30 +47,102 @@ function getAddressWasm( return wasmAddress; } -async function getFixtures(name: string): Promise { +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, + 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); - const scriptFromAddress = utxolibCompat.Address.toOutputScript(address, network); + + // Test decoding (address -> script) + const scriptFromAddress = toOutputScriptWithCoin(addressRef, coinName); assert.deepStrictEqual(Buffer.from(scriptFromAddress), scriptBuf); } }); @@ -89,4 +168,8 @@ function runTest(network: utxolib.Network) { utxolib.getNetworkList().forEach((network) => { runTest(network); + const mainnet = utxolib.getMainnet(network); + if (mainnet === utxolib.networks.bitcoincash || mainnet === utxolib.networks.ecash) { + runTest(network, "cashaddr"); + } }); From 6a923e3a2cfa3b2505d6f5ea052d21b18db589da Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Fri, 24 Oct 2025 15:15:19 +0200 Subject: [PATCH 5/5] docs(wasm-utxo): update project status in README Update feature matrix to mark FixedScript Wallet address generation as complete for all altcoins. Issue: BTC-2652 Co-authored-by: llm-git --- packages/wasm-utxo/README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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