From 7e7d78cb9d0c4eeb69d82e43e4642093ebf245fe Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 29 Oct 2025 09:20:36 +0100 Subject: [PATCH 1/3] feat(wasm-utxo): add optional addressFormat parameter to address function Add support for specifying the address format when generating addresses in the fixedScriptWallet module. This allows controlling the output format (e.g., cashaddr for BCH/ECASH) when needed. Added JSDoc documentation explaining the addressFormat parameter usage and the available format options for Bitcoin Cash and eCash networks. Issue: BTC-2652 Co-authored-by: llm-git --- packages/wasm-utxo/js/address.ts | 4 ++ packages/wasm-utxo/js/fixedScriptWallet.ts | 12 +++++- .../wasm-utxo/src/fixed_script_wallet/mod.rs | 5 ++- .../wasm-utxo/test/fixedScript/address.ts | 41 ++++++++++++++++--- 4 files changed, 54 insertions(+), 8 deletions(-) diff --git a/packages/wasm-utxo/js/address.ts b/packages/wasm-utxo/js/address.ts index 7ca01aa..4298a1d 100644 --- a/packages/wasm-utxo/js/address.ts +++ b/packages/wasm-utxo/js/address.ts @@ -1,6 +1,10 @@ import { AddressNamespace } from "./wasm/wasm_utxo"; import type { CoinName } from "./coinName"; +/** + * Most coins only have one unambiguous address format (base58check and bech32/bech32m) + * For Bitcoin Cash and eCash, we can select between base58check and cashaddr. + */ export type AddressFormat = "default" | "cashaddr"; export function toOutputScriptWithCoin(address: string, coin: CoinName): Uint8Array { diff --git a/packages/wasm-utxo/js/fixedScriptWallet.ts b/packages/wasm-utxo/js/fixedScriptWallet.ts index 3e25c36..4df7115 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet.ts @@ -1,6 +1,7 @@ import { FixedScriptWalletNamespace } from "./wasm/wasm_utxo"; import type { UtxolibNetwork, UtxolibRootWalletKeys } from "./utxolibCompat"; import { Triple } from "./triple"; +import { AddressFormat } from "./address"; export type WalletKeys = /** Just an xpub triple, will assume default derivation prefixes */ @@ -18,12 +19,21 @@ export function outputScript(keys: WalletKeys, chain: number, index: number): Ui /** * Create the address for a given wallet keys and chain and index and network. * Wrapper for outputScript that also encodes the script to an address. + * @param keys - The wallet keys to use. + * @param chain - The chain to use. + * @param index - The index to use. + * @param network - The network to use. + * @param addressFormat - The address format to use. + * Only relevant for Bitcoin Cash and eCash networks, where: + * - "default" means base58check, + * - "cashaddr" means cashaddr. */ export function address( keys: WalletKeys, chain: number, index: number, network: UtxolibNetwork, + addressFormat?: AddressFormat, ): string { - return FixedScriptWalletNamespace.address(keys, chain, index, network); + return FixedScriptWalletNamespace.address(keys, chain, index, network, addressFormat); } diff --git a/packages/wasm-utxo/src/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/mod.rs index f45c5bd..9436979 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/mod.rs @@ -42,6 +42,7 @@ impl FixedScriptWalletNamespace { chain: u32, index: u32, network: JsValue, + address_format: Option, ) -> Result { let network = Network::try_from_js_value(&network)?; let wallet_keys = RootWalletKeys::from_jsvalue(&keys)?; @@ -49,10 +50,12 @@ impl FixedScriptWalletNamespace { .map_err(|e| WasmMiniscriptError::new(&format!("Invalid chain: {}", e)))?; let scripts = WalletScripts::from_wallet_keys(&wallet_keys, chain, index); let script = scripts.output_script(); + let address_format = AddressFormat::from_optional_str(address_format.as_deref()) + .map_err(|e| WasmMiniscriptError::new(&format!("Invalid address format: {}", e)))?; let address = crate::address::utxolib_compat::from_output_script_with_network( &script, &network, - AddressFormat::Default, + address_format, ) .map_err(|e| WasmMiniscriptError::new(&format!("Failed to generate address: {}", e)))?; Ok(address.to_string()) diff --git a/packages/wasm-utxo/test/fixedScript/address.ts b/packages/wasm-utxo/test/fixedScript/address.ts index 2c4758f..8c68d9e 100644 --- a/packages/wasm-utxo/test/fixedScript/address.ts +++ b/packages/wasm-utxo/test/fixedScript/address.ts @@ -2,7 +2,7 @@ import assert from "node:assert"; import * as utxolib from "@bitgo/utxo-lib"; -import { fixedScriptWallet } from "../../js"; +import { AddressFormat, fixedScriptWallet } from "../../js"; type Triple = [T, T, T]; @@ -11,6 +11,7 @@ function getAddressUtxoLib( chain: number, index: number, network: utxolib.Network, + addressFormat: AddressFormat, ): string { if (!utxolib.bitgo.isChainCode(chain)) { throw new Error(`Invalid chain code: ${chain}`); @@ -21,11 +22,21 @@ function getAddressUtxoLib( derived.publicKeys, utxolib.bitgo.outputScripts.scriptTypeForChain(chain), ); - const address = utxolib.address.fromOutputScript(script.scriptPubKey, network); + const address = utxolib.addressFormat.fromOutputScriptWithFormat( + script.scriptPubKey, + addressFormat, + network, + ); return address; } -function runTest(network: utxolib.Network, derivationPrefixes?: Triple) { +function runTest( + network: utxolib.Network, + { + derivationPrefixes, + addressFormat, + }: { derivationPrefixes?: Triple; addressFormat?: AddressFormat } = {}, +) { describe(`address for network ${utxolib.getNetworkName(network)}, derivationPrefixes=${Boolean(derivationPrefixes)}`, function () { const keyTriple = utxolib.testutil.getKeyTriple("wasm"); @@ -41,8 +52,20 @@ function runTest(network: utxolib.Network, derivationPrefixes?: Triple) keyTriple.map((k) => k.neutered()) as Triple, derivationPrefixes, ); - const utxolibAddress = getAddressUtxoLib(rootWalletKeys, chainCode, index, network); - const wasmAddress = fixedScriptWallet.address(rootWalletKeys, chainCode, index, network); + const utxolibAddress = getAddressUtxoLib( + rootWalletKeys, + chainCode, + index, + network, + addressFormat ?? "default", + ); + const wasmAddress = fixedScriptWallet.address( + rootWalletKeys, + chainCode, + index, + network, + addressFormat, + ); assert.strictEqual(utxolibAddress, wasmAddress); } } @@ -52,5 +75,11 @@ function runTest(network: utxolib.Network, derivationPrefixes?: Triple) utxolib.getNetworkList().forEach((network) => { runTest(network); - runTest(network, ["m/1/2", "m/0/0", "m/0/0"]); + runTest(network, { derivationPrefixes: ["m/1/2", "m/0/0", "m/0/0"] }); + if ( + utxolib.getMainnet(network) === utxolib.networks.bitcoincash || + utxolib.getMainnet(network) === utxolib.networks.ecash + ) { + runTest(network, { addressFormat: "cashaddr" }); + } }); From 217fa9f0ae2e6f2deafe871c475b670948a5353c Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 29 Oct 2025 10:36:47 +0100 Subject: [PATCH 2/3] feat(wasm-utxo): add network parameter to output script validation Add proper validation for supported script types based on network capabilities. This adds checks for SegWit and Taproot support in the output script generation methods, ensuring scripts are only created for networks that support them. - Add OutputScriptSupport struct to verify script compatibility - Add network parameter to outputScript function - Implement per-network script validation logic - Document supported features across different networks Issue: BTC-2652 Co-authored-by: llm-git --- packages/wasm-utxo/js/fixedScriptWallet.ts | 9 +- packages/wasm-utxo/src/address/networks.rs | 325 ++++++++++++++++++ .../wasm-utxo/src/address/utxolib_compat.rs | 17 +- .../wasm-utxo/src/fixed_script_wallet/mod.rs | 8 +- .../src/fixed_script_wallet/test_utils/mod.rs | 11 +- .../fixed_script_wallet/wallet_scripts/mod.rs | 148 +++++++- .../wasm-utxo/test/fixedScript/address.ts | 40 ++- 7 files changed, 532 insertions(+), 26 deletions(-) diff --git a/packages/wasm-utxo/js/fixedScriptWallet.ts b/packages/wasm-utxo/js/fixedScriptWallet.ts index 4df7115..65641a2 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet.ts @@ -12,8 +12,13 @@ export type WalletKeys = /** * Create the output script for a given wallet keys and chain and index */ -export function outputScript(keys: WalletKeys, chain: number, index: number): Uint8Array { - return FixedScriptWalletNamespace.output_script(keys, chain, index); +export function outputScript( + keys: WalletKeys, + chain: number, + index: number, + network: UtxolibNetwork, +): Uint8Array { + return FixedScriptWalletNamespace.output_script(keys, chain, index, network); } /** diff --git a/packages/wasm-utxo/src/address/networks.rs b/packages/wasm-utxo/src/address/networks.rs index f206335..84c0d74 100644 --- a/packages/wasm-utxo/src/address/networks.rs +++ b/packages/wasm-utxo/src/address/networks.rs @@ -71,12 +71,106 @@ impl AddressFormat { } } +pub struct OutputScriptSupport { + pub segwit: bool, + pub taproot: bool, +} + +impl OutputScriptSupport { + pub(crate) fn assert_legacy(&self) -> Result<()> { + // all coins support legacy scripts + Ok(()) + } + + pub(crate) fn assert_segwit(&self) -> Result<()> { + if !self.segwit { + return Err(AddressError::UnsupportedScriptType( + "Network does not support segwit".to_string(), + )); + } + Ok(()) + } + + pub(crate) fn assert_taproot(&self) -> Result<()> { + if !self.taproot { + return Err(AddressError::UnsupportedScriptType( + "Network does not support taproot".to_string(), + )); + } + Ok(()) + } + + pub fn assert_support(&self, script: &Script) -> Result<()> { + match script.witness_version() { + None => { + // all coins support legacy scripts + } + Some(WitnessVersion::V0) => { + self.assert_segwit()?; + } + Some(WitnessVersion::V1) => { + self.assert_taproot()?; + } + _ => { + return Err(AddressError::UnsupportedScriptType( + "Unsupported witness version".to_string(), + )); + } + } + Ok(()) + } +} + +impl Network { + pub fn output_script_support(&self) -> OutputScriptSupport { + // SegWit support: + // Bitcoin: SegWit activated August 24, 2017 at block 481,824 + // - Consensus rules: https://github.com/bitcoin/bitcoin/blob/v28.0/src/consensus/tx_verify.cpp + // - Witness validation: https://github.com/bitcoin/bitcoin/blob/v28.0/src/script/interpreter.cpp + // - BIP141 (SegWit): https://github.com/bitcoin/bips/blob/master/bip-0141.mediawiki + // - BIP143 (Signature verification): https://github.com/bitcoin/bips/blob/master/bip-0143.mediawiki + // - BIP144 (P2P changes): https://github.com/bitcoin/bips/blob/master/bip-0144.mediawiki + // + // Litecoin: SegWit activated May 10, 2017 at block 1,201,536 + // - Consensus implementation: https://github.com/litecoin-project/litecoin/blob/v0.21.4/src/consensus/tx_verify.cpp + // - Script interpreter: https://github.com/litecoin-project/litecoin/blob/v0.21.4/src/script/interpreter.cpp + // + // Bitcoin Gold: Launched with SegWit support in October 2017 + // - Implementation: https://github.com/BTCGPU/BTCGPU/blob/v0.17.3/src/consensus/tx_verify.cpp + let segwit = matches!( + self.mainnet(), + Network::Bitcoin | Network::Litecoin | Network::BitcoinGold + ); + + // Taproot support: + // Bitcoin: Taproot activated November 14, 2021 at block 709,632 + // - Taproot validation: https://github.com/bitcoin/bitcoin/blob/v28.0/src/script/interpreter.cpp + // (see VerifyWitnessProgram, WITNESS_V1_TAPROOT) + // - Schnorr signature verification: https://github.com/bitcoin/bitcoin/blob/v28.0/src/pubkey.cpp + // (see XOnlyPubKey::VerifySchnorr) + // - Deployment params: https://github.com/bitcoin/bitcoin/blob/v28.0/src/kernel/chainparams.cpp + // (see Consensus::DeploymentPos::DEPLOYMENT_TAPROOT) + // - BIP340 (Schnorr signatures): https://github.com/bitcoin/bips/blob/master/bip-0340.mediawiki + // - BIP341 (Taproot): https://github.com/bitcoin/bips/blob/master/bip-0341.mediawiki + // - BIP342 (Tapscript): https://github.com/bitcoin/bips/blob/master/bip-0342.mediawiki + // + // Litecoin: has apparent taproot support, but we have not enabled it in this library yet. + // - https://github.com/litecoin-project/litecoin/blob/v0.21.4/src/chainparams.cpp#L89-L92 + // - https://github.com/litecoin-project/litecoin/blob/v0.21.4/src/script/interpreter.h#L129-L131 + let taproot = segwit && matches!(self.mainnet(), Network::Bitcoin); + + OutputScriptSupport { segwit, taproot } + } +} + /// 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> { + network.output_script_support().assert_support(script)?; + let is_witness = script.is_p2wpkh() || script.is_p2wsh() || script.is_p2tr(); let is_legacy = script.is_p2pkh() || script.is_p2sh(); @@ -208,6 +302,7 @@ pub fn from_output_script_with_coin_and_format( from_output_script_with_network_and_format(script, network, format) } +use miniscript::bitcoin::WitnessVersion; // WASM bindings use wasm_bindgen::prelude::*; @@ -476,4 +571,234 @@ mod tests { .to_string() .contains("Unknown address format")); } + + #[test] + fn test_output_script_support_assert_legacy() { + // Legacy should always succeed regardless of support flags + let support_none = OutputScriptSupport { + segwit: false, + taproot: false, + }; + assert!(support_none.assert_legacy().is_ok()); + + let support_all = OutputScriptSupport { + segwit: true, + taproot: true, + }; + assert!(support_all.assert_legacy().is_ok()); + } + + #[test] + fn test_output_script_support_assert_segwit() { + // Should succeed when segwit is supported + let support_segwit = OutputScriptSupport { + segwit: true, + taproot: false, + }; + assert!(support_segwit.assert_segwit().is_ok()); + + // Should fail when segwit is not supported + let no_support = OutputScriptSupport { + segwit: false, + taproot: false, + }; + let result = no_support.assert_segwit(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Network does not support segwit")); + } + + #[test] + fn test_output_script_support_assert_taproot() { + // Should succeed when taproot is supported + let support_taproot = OutputScriptSupport { + segwit: true, + taproot: true, + }; + assert!(support_taproot.assert_taproot().is_ok()); + + // Should fail when taproot is not supported + let no_support = OutputScriptSupport { + segwit: true, + taproot: false, + }; + let result = no_support.assert_taproot(); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Network does not support taproot")); + } + + #[test] + fn test_output_script_support_assert_support_legacy() { + // Test with legacy P2PKH script + let hash = hex::decode("62e907b15cbf27d5425399ebf6f0fb50ebb88f18").unwrap(); + let pubkey_hash = PubkeyHash::from_byte_array(hash.try_into().unwrap()); + let p2pkh_script = ScriptBuf::new_p2pkh(&pubkey_hash); + + // Legacy scripts should work even without segwit/taproot support + let no_support = OutputScriptSupport { + segwit: false, + taproot: false, + }; + assert!(no_support.assert_support(&p2pkh_script).is_ok()); + + // Test with legacy P2SH script + let hash2 = hex::decode("89abcdef89abcdef89abcdef89abcdef89abcdef").unwrap(); + let script_hash = crate::bitcoin::ScriptHash::from_byte_array(hash2.try_into().unwrap()); + let p2sh_script = ScriptBuf::new_p2sh(&script_hash); + assert!(no_support.assert_support(&p2sh_script).is_ok()); + } + + #[test] + fn test_output_script_support_assert_support_segwit() { + // Test with P2WPKH script (witness v0) + let hash = hex::decode("751e76e8199196d454941c45d1b3a323f1433bd6").unwrap(); + let wpkh = crate::bitcoin::WPubkeyHash::from_byte_array(hash.try_into().unwrap()); + let p2wpkh_script = ScriptBuf::new_p2wpkh(&wpkh); + + // Should succeed with segwit support + let support_segwit = OutputScriptSupport { + segwit: true, + taproot: false, + }; + assert!(support_segwit.assert_support(&p2wpkh_script).is_ok()); + + // Should fail without segwit support + let no_support = OutputScriptSupport { + segwit: false, + taproot: false, + }; + let result = no_support.assert_support(&p2wpkh_script); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Network does not support segwit")); + + // Test with P2WSH script (witness v0) + let wsh = crate::bitcoin::WScriptHash::from_byte_array( + hex::decode("751e76e8199196d454941c45d1b3a323f1433bd6751e76e8199196d454941c45") + .unwrap() + .try_into() + .unwrap(), + ); + let p2wsh_script = ScriptBuf::new_p2wsh(&wsh); + assert!(support_segwit.assert_support(&p2wsh_script).is_ok()); + assert!(no_support.assert_support(&p2wsh_script).is_err()); + } + + #[test] + fn test_output_script_support_assert_support_taproot() { + // Test with P2TR script (witness v1) + use crate::bitcoin::secp256k1::{Secp256k1, XOnlyPublicKey}; + + let secp = Secp256k1::verification_only(); + // Use a fixed x-only public key for testing + let xonly_pk = XOnlyPublicKey::from_slice( + &hex::decode("cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115") + .unwrap(), + ) + .unwrap(); + let p2tr_script = ScriptBuf::new_p2tr(&secp, xonly_pk, None); + + // Should succeed with taproot support + let support_taproot = OutputScriptSupport { + segwit: true, + taproot: true, + }; + assert!(support_taproot.assert_support(&p2tr_script).is_ok()); + + // Should fail without taproot support (but with segwit) + let no_taproot = OutputScriptSupport { + segwit: true, + taproot: false, + }; + let result = no_taproot.assert_support(&p2tr_script); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Network does not support taproot")); + + // Should also fail without segwit or taproot + let no_support = OutputScriptSupport { + segwit: false, + taproot: false, + }; + let result = no_support.assert_support(&p2tr_script); + assert!(result.is_err()); + } + + #[test] + fn test_output_script_support_network_specific() { + // Test Bitcoin - should support segwit and taproot + let btc_support = Network::Bitcoin.output_script_support(); + assert!(btc_support.segwit); + assert!(btc_support.taproot); + + // Test Bitcoin testnet - should support segwit and taproot + let tbtc_support = Network::BitcoinTestnet3.output_script_support(); + assert!(tbtc_support.segwit); + assert!(tbtc_support.taproot); + + // Test Litecoin - should support segwit but not taproot + let ltc_support = Network::Litecoin.output_script_support(); + assert!(ltc_support.segwit); + assert!(!ltc_support.taproot); + + // Test Bitcoin Gold - should support segwit but not taproot + let btg_support = Network::BitcoinGold.output_script_support(); + assert!(btg_support.segwit); + assert!(!btg_support.taproot); + + // Test Dogecoin - should not support segwit or taproot + let doge_support = Network::Dogecoin.output_script_support(); + assert!(!doge_support.segwit); + assert!(!doge_support.taproot); + + // Test Bitcoin Cash - should not support segwit or taproot + let bch_support = Network::BitcoinCash.output_script_support(); + assert!(!bch_support.segwit); + assert!(!bch_support.taproot); + } + + #[test] + fn test_get_encode_codec_enforces_script_support() { + // Test that get_encode_codec enforces script support via assert_support + + // P2WPKH on Bitcoin should work + let hash = hex::decode("751e76e8199196d454941c45d1b3a323f1433bd6").unwrap(); + let wpkh = crate::bitcoin::WPubkeyHash::from_byte_array(hash.try_into().unwrap()); + let p2wpkh_script = ScriptBuf::new_p2wpkh(&wpkh); + assert!(get_encode_codec(Network::Bitcoin, &p2wpkh_script, AddressFormat::Default).is_ok()); + + // P2WPKH on Bitcoin Cash should fail (no segwit support) + let result = get_encode_codec(Network::BitcoinCash, &p2wpkh_script, AddressFormat::Default); + assert!(result.is_err()); + if let Err(e) = result { + assert!(e.to_string().contains("Network does not support segwit")); + } + + // P2TR on Bitcoin should work + use crate::bitcoin::secp256k1::{Secp256k1, XOnlyPublicKey}; + let secp = Secp256k1::verification_only(); + let xonly_pk = XOnlyPublicKey::from_slice( + &hex::decode("cc8a4bc64d897bddc5fbc2f670f7a8ba0b386779106cf1223c6fc5d7cd6fc115") + .unwrap(), + ) + .unwrap(); + let p2tr_script = ScriptBuf::new_p2tr(&secp, xonly_pk, None); + assert!(get_encode_codec(Network::Bitcoin, &p2tr_script, AddressFormat::Default).is_ok()); + + // P2TR on Litecoin should fail (no taproot support) + let result = get_encode_codec(Network::Litecoin, &p2tr_script, AddressFormat::Default); + assert!(result.is_err()); + if let Err(e) = result { + assert!(e.to_string().contains("Network does not support taproot")); + } + } } diff --git a/packages/wasm-utxo/src/address/utxolib_compat.rs b/packages/wasm-utxo/src/address/utxolib_compat.rs index 8057194..693bdc6 100644 --- a/packages/wasm-utxo/src/address/utxolib_compat.rs +++ b/packages/wasm-utxo/src/address/utxolib_compat.rs @@ -3,7 +3,7 @@ /// but for now we need to keep this compatibility layer. use wasm_bindgen::JsValue; -use crate::address::networks::AddressFormat; +use crate::address::networks::{AddressFormat, OutputScriptSupport}; use crate::address::{bech32, cashaddr, Base58CheckCodec}; use crate::bitcoin::{Script, ScriptBuf}; @@ -31,6 +31,19 @@ impl Network { Network::try_from_js_value(js_network) .map_err(|e| AddressError::InvalidAddress(e.to_string())) } + pub fn output_script_support(&self) -> OutputScriptSupport { + let segwit = self.bech32.is_some(); + + // In the context of this library, only bitcoin supports taproot + // See output_script_support in networks.rs for detailed references + let taproot = segwit + && self + .bech32 + .as_ref() + .is_some_and(|bech32| bech32 == "bc" || bech32 == "tb"); + + OutputScriptSupport { segwit, taproot } + } } /// Convert output script to address string using a utxolib Network object @@ -39,6 +52,8 @@ pub fn from_output_script_with_network( network: &Network, format: AddressFormat, ) -> Result { + network.output_script_support().assert_support(script)?; + // Handle cashaddr format if requested if matches!(format, AddressFormat::Cashaddr) { if let Some(ref cash_addr) = network.cash_addr { diff --git a/packages/wasm-utxo/src/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/mod.rs index 9436979..41c83e4 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/mod.rs @@ -12,7 +12,7 @@ pub use wallet_keys::*; pub use wallet_scripts::*; use wasm_bindgen::prelude::*; -use crate::address::networks::AddressFormat; +use crate::address::networks::{AddressFormat}; use crate::error::WasmMiniscriptError; use crate::try_from_js_value::TryFromJsValue; use crate::utxolib_compat::Network; @@ -27,12 +27,14 @@ impl FixedScriptWalletNamespace { keys: JsValue, chain: u32, index: u32, + network: JsValue, ) -> Result, WasmMiniscriptError> { + let network = Network::try_from_js_value(&network)?; let chain = Chain::try_from(chain) .map_err(|e| WasmMiniscriptError::new(&format!("Invalid chain: {}", e)))?; let wallet_keys = RootWalletKeys::from_jsvalue(&keys)?; - let scripts = WalletScripts::from_wallet_keys(&wallet_keys, chain, index); + let scripts = WalletScripts::from_wallet_keys(&wallet_keys, chain, index, &network.output_script_support())?; Ok(scripts.output_script().to_bytes()) } @@ -48,7 +50,7 @@ impl FixedScriptWalletNamespace { let wallet_keys = RootWalletKeys::from_jsvalue(&keys)?; let chain = Chain::try_from(chain) .map_err(|e| WasmMiniscriptError::new(&format!("Invalid chain: {}", e)))?; - let scripts = WalletScripts::from_wallet_keys(&wallet_keys, chain, index); + let scripts = WalletScripts::from_wallet_keys(&wallet_keys, chain, index, &network.output_script_support())?; let script = scripts.output_script(); let address_format = AddressFormat::from_optional_str(address_format.as_deref()) .map_err(|e| WasmMiniscriptError::new(&format!("Invalid address format: {}", e)))?; diff --git a/packages/wasm-utxo/src/fixed_script_wallet/test_utils/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/test_utils/mod.rs index 33c2117..f1670f7 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/test_utils/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/test_utils/mod.rs @@ -7,7 +7,7 @@ use super::wallet_scripts::{Chain, WalletScripts}; use crate::bitcoin::bip32::{DerivationPath, Fingerprint, Xpriv, Xpub}; use crate::bitcoin::psbt::{Input as PsbtInput, Output as PsbtOutput, Psbt}; use crate::bitcoin::{Transaction, TxIn, TxOut}; -use crate::RootWalletKeys; +use crate::{Network, RootWalletKeys}; use std::collections::BTreeMap; use std::str::FromStr; @@ -32,8 +32,13 @@ pub fn get_test_wallet_keys(seed: &str) -> XpubTriple { /// Create a PSBT output for an external wallet (different keys) pub fn create_external_output(seed: &str) -> PsbtOutput { let xpubs = get_test_wallet_keys(seed); - let _scripts = - WalletScripts::from_wallet_keys(&RootWalletKeys::new(xpubs), Chain::P2wshExternal, 0); + let _scripts = WalletScripts::from_wallet_keys( + &RootWalletKeys::new(xpubs), + Chain::P2wshExternal, + 0, + &Network::Bitcoin.output_script_support(), + ) + .unwrap(); PsbtOutput { bip32_derivation: BTreeMap::new(), // witness_script: scripts.witness_script, diff --git a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs index 25b17fd..1a5c816 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs @@ -12,8 +12,10 @@ pub use checkmultisig::{ pub use checksigverify::{build_p2tr_ns_script, ScriptP2tr}; pub use singlesig::{build_p2pk_script, ScriptP2shP2pk}; +use crate::address::networks::OutputScriptSupport; use crate::bitcoin::bip32::{ChildNumber, DerivationPath}; use crate::bitcoin::ScriptBuf; +use crate::error::WasmMiniscriptError; use crate::fixed_script_wallet::wallet_keys::{to_pub_triple, PubTriple, XpubTriple}; use crate::RootWalletKeys; use std::convert::TryFrom; @@ -51,32 +53,41 @@ impl std::fmt::Display for WalletScripts { } impl WalletScripts { - pub fn new(keys: &PubTriple, chain: Chain) -> WalletScripts { + pub fn new( + keys: &PubTriple, + chain: Chain, + script_support: &OutputScriptSupport, + ) -> Result { match chain { Chain::P2shExternal | Chain::P2shInternal => { + script_support.assert_legacy()?; let script = build_multisig_script_2_of_3(keys); - WalletScripts::P2sh(ScriptP2sh { + Ok(WalletScripts::P2sh(ScriptP2sh { redeem_script: script, - }) + })) } Chain::P2shP2wshExternal | Chain::P2shP2wshInternal => { + script_support.assert_segwit()?; let script = build_multisig_script_2_of_3(keys); - WalletScripts::P2shP2wsh(ScriptP2shP2wsh { + Ok(WalletScripts::P2shP2wsh(ScriptP2shP2wsh { redeem_script: script.clone().to_p2wsh(), witness_script: script, - }) + })) } Chain::P2wshExternal | Chain::P2wshInternal => { + script_support.assert_segwit()?; let script = build_multisig_script_2_of_3(keys); - WalletScripts::P2wsh(ScriptP2wsh { + Ok(WalletScripts::P2wsh(ScriptP2wsh { witness_script: script, - }) + })) } Chain::P2trInternal | Chain::P2trExternal => { - WalletScripts::P2trLegacy(ScriptP2tr::new(keys, false)) + script_support.assert_taproot()?; + Ok(WalletScripts::P2trLegacy(ScriptP2tr::new(keys, false))) } Chain::P2trMusig2Internal | Chain::P2trMusig2External => { - WalletScripts::P2trMusig2(ScriptP2tr::new(keys, true)) + script_support.assert_taproot()?; + Ok(WalletScripts::P2trMusig2(ScriptP2tr::new(keys, true))) } } } @@ -85,11 +96,12 @@ impl WalletScripts { wallet_keys: &RootWalletKeys, chain: Chain, index: u32, - ) -> WalletScripts { + script_support: &OutputScriptSupport, + ) -> Result { let derived_keys = wallet_keys .derive_for_chain_and_index(chain as u32, index) .unwrap(); - WalletScripts::new(&to_pub_triple(&derived_keys), chain) + WalletScripts::new(&to_pub_triple(&derived_keys), chain, script_support) } pub fn output_script(&self) -> ScriptBuf { @@ -199,9 +211,16 @@ mod tests { use super::*; use crate::fixed_script_wallet::test_utils::fixtures; use crate::fixed_script_wallet::wallet_keys::tests::get_test_wallet_keys; + use crate::Network; fn assert_output_script(keys: &RootWalletKeys, chain: Chain, expected_script: &str) { - let scripts = WalletScripts::from_wallet_keys(keys, chain, 0); + let scripts = WalletScripts::from_wallet_keys( + keys, + chain, + 0, + &Network::Bitcoin.output_script_support(), + ) + .unwrap(); let output_script = scripts.output_script(); assert_eq!(output_script.to_hex_string(), expected_script); } @@ -382,7 +401,13 @@ mod tests { let (chain, index) = parse_fixture_paths(input_fixture).expect("Failed to parse fixture paths"); - let scripts = WalletScripts::from_wallet_keys(&wallet_keys, chain, index); + let scripts = WalletScripts::from_wallet_keys( + &wallet_keys, + chain, + index, + &Network::Bitcoin.output_script_support(), + ) + .expect("Failed to create wallet scripts"); // Use the new helper methods for validation match (scripts, input_fixture) { @@ -470,4 +495,101 @@ mod tests { fn test_p2tr_musig2_key_path_spend_script_generation_from_fixture() { test_wallet_script_type("taprootKeypath").unwrap(); } + + #[test] + fn test_script_support_rejects_unsupported_script_types() { + let keys = get_test_wallet_keys("test"); + + // Test segwit rejection: try to create P2wsh on a network without segwit support + let no_segwit_support = OutputScriptSupport { + segwit: false, + taproot: false, + }; + + let result = + WalletScripts::from_wallet_keys(&keys, Chain::P2wshExternal, 0, &no_segwit_support); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Network does not support segwit")); + + let result = + WalletScripts::from_wallet_keys(&keys, Chain::P2shP2wshExternal, 0, &no_segwit_support); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Network does not support segwit")); + + // Test taproot rejection: try to create P2tr on a network without taproot support + let no_taproot_support = OutputScriptSupport { + segwit: true, + taproot: false, + }; + + let result = + WalletScripts::from_wallet_keys(&keys, Chain::P2trExternal, 0, &no_taproot_support); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Network does not support taproot")); + + let result = WalletScripts::from_wallet_keys( + &keys, + Chain::P2trMusig2External, + 0, + &no_taproot_support, + ); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Network does not support taproot")); + + // Test that legacy scripts work regardless of support flags + let result = + WalletScripts::from_wallet_keys(&keys, Chain::P2shExternal, 0, &no_segwit_support); + assert!(result.is_ok()); + + // Test real-world network scenarios + // Dogecoin doesn't support segwit or taproot + let doge_support = Network::Dogecoin.output_script_support(); + let result = WalletScripts::from_wallet_keys(&keys, Chain::P2wshExternal, 0, &doge_support); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Network does not support segwit")); + + // Litecoin supports segwit but not taproot + let ltc_support = Network::Litecoin.output_script_support(); + let result = WalletScripts::from_wallet_keys(&keys, Chain::P2trExternal, 0, <c_support); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Network does not support taproot")); + + // Litecoin should support segwit scripts + let result = WalletScripts::from_wallet_keys(&keys, Chain::P2wshExternal, 0, <c_support); + assert!(result.is_ok()); + + // Bitcoin should support all script types + let btc_support = Network::Bitcoin.output_script_support(); + assert!( + WalletScripts::from_wallet_keys(&keys, Chain::P2shExternal, 0, &btc_support).is_ok() + ); + assert!( + WalletScripts::from_wallet_keys(&keys, Chain::P2wshExternal, 0, &btc_support).is_ok() + ); + assert!( + WalletScripts::from_wallet_keys(&keys, Chain::P2trExternal, 0, &btc_support).is_ok() + ); + assert!( + WalletScripts::from_wallet_keys(&keys, Chain::P2trMusig2External, 0, &btc_support) + .is_ok() + ); + } } diff --git a/packages/wasm-utxo/test/fixedScript/address.ts b/packages/wasm-utxo/test/fixedScript/address.ts index 8c68d9e..5156af7 100644 --- a/packages/wasm-utxo/test/fixedScript/address.ts +++ b/packages/wasm-utxo/test/fixedScript/address.ts @@ -40,6 +40,11 @@ function runTest( describe(`address for network ${utxolib.getNetworkName(network)}, derivationPrefixes=${Boolean(derivationPrefixes)}`, function () { const keyTriple = utxolib.testutil.getKeyTriple("wasm"); + const rootWalletKeys = new utxolib.bitgo.RootWalletKeys( + keyTriple.map((k) => k.neutered()) as Triple, + derivationPrefixes, + ); + const supportedChainCodes = utxolib.bitgo.chainCodes.filter((chainCode) => { const scriptType = utxolib.bitgo.outputScripts.scriptTypeForChain(chainCode); return utxolib.bitgo.outputScripts.isSupportedScriptType(network, scriptType); @@ -48,10 +53,6 @@ function runTest( 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 rootWalletKeys = new utxolib.bitgo.RootWalletKeys( - keyTriple.map((k) => k.neutered()) as Triple, - derivationPrefixes, - ); const utxolibAddress = getAddressUtxoLib( rootWalletKeys, chainCode, @@ -70,6 +71,37 @@ function runTest( } } }); + + const unsupportedChainCodes = utxolib.bitgo.chainCodes.filter((chainCode) => { + const scriptType = utxolib.bitgo.outputScripts.scriptTypeForChain(chainCode); + return !utxolib.bitgo.outputScripts.isSupportedScriptType(network, scriptType); + }); + + if (unsupportedChainCodes.length > 0) { + it(`throws error for unsupported chain codes ${unsupportedChainCodes.join(", ")}`, function () { + for (const chainCode of unsupportedChainCodes) { + const scriptType = utxolib.bitgo.outputScripts.scriptTypeForChain(chainCode); + assert.throws( + () => { + fixedScriptWallet.address(rootWalletKeys, chainCode, 0, network, addressFormat); + }, + (error: Error) => { + const errorMessage = error.message.toLowerCase(); + const isSegwitError = scriptType === "p2shP2wsh" || scriptType === "p2wsh"; + const isTaprootError = scriptType === "p2tr" || scriptType === "p2trMusig2"; + + if (isSegwitError) { + return errorMessage.includes("does not support segwit"); + } else if (isTaprootError) { + return errorMessage.includes("does not support taproot"); + } + return false; + }, + `Expected error for unsupported script type ${scriptType} on network ${utxolib.getNetworkName(network)}`, + ); + } + }); + } }); } From 20951a559a324b5f8b81a6b3f28902110292089b Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 29 Oct 2025 10:42:41 +0100 Subject: [PATCH 3/3] feat(wasm-utxo): rename Network to UtxolibNetwork for better semantics Rename Network class to UtxolibNetwork to better represent its purpose as a compatibility layer with utxo-lib. This helps avoid confusion with other network objects while maintaining functionality. Issue: BTC-2652 Co-authored-by: llm-git --- .../wasm-utxo/src/address/utxolib_compat.rs | 33 +++++++++++-------- .../wasm-utxo/src/fixed_script_wallet/mod.rs | 22 +++++++++---- packages/wasm-utxo/src/try_from_js_value.rs | 6 ++-- 3 files changed, 38 insertions(+), 23 deletions(-) diff --git a/packages/wasm-utxo/src/address/utxolib_compat.rs b/packages/wasm-utxo/src/address/utxolib_compat.rs index 693bdc6..c792225 100644 --- a/packages/wasm-utxo/src/address/utxolib_compat.rs +++ b/packages/wasm-utxo/src/address/utxolib_compat.rs @@ -17,18 +17,23 @@ pub struct CashAddr { pub script_hash: u32, } -pub struct Network { +/// This maps to the structure of `utxolib.Network` so that we can use it for address encoding/decoding. +/// We also use it to infer the output script support for the network. +/// +/// We cannot precisely map it to the proper `networks::Network` enum because certain +/// different networks are structurally identical (all bitcoin testnets). +pub struct UtxolibNetwork { pub pub_key_hash: u32, pub script_hash: u32, pub cash_addr: Option, pub bech32: Option, } -impl Network { - /// Parse a Network object from a JavaScript value +impl UtxolibNetwork { + /// Parse a UtxolibNetwork object from a JavaScript value pub fn from_js_value(js_network: &JsValue) -> Result { use crate::try_from_js_value::TryFromJsValue; - Network::try_from_js_value(js_network) + UtxolibNetwork::try_from_js_value(js_network) .map_err(|e| AddressError::InvalidAddress(e.to_string())) } pub fn output_script_support(&self) -> OutputScriptSupport { @@ -46,10 +51,10 @@ impl Network { } } -/// Convert output script to address string using a utxolib Network object +/// Convert output script to address string using a utxolib UtxolibNetwork object pub fn from_output_script_with_network( script: &Script, - network: &Network, + network: &UtxolibNetwork, format: AddressFormat, ) -> Result { network.output_script_support().assert_support(script)?; @@ -94,8 +99,8 @@ pub fn from_output_script_with_network( } } -/// Convert address string to output script using a utxolib Network object -pub fn to_output_script_with_network(address: &str, network: &Network) -> Result { +/// Convert address string to output script using a utxolib UtxolibNetwork object +pub fn to_output_script_with_network(address: &str, network: &UtxolibNetwork) -> Result { use crate::address::AddressCodec; use crate::bitcoin::hashes::Hash; use crate::bitcoin::{PubkeyHash, ScriptHash}; @@ -148,7 +153,7 @@ impl UtxolibCompatNamespace { /// /// # Arguments /// * `script` - The output script as a byte array - /// * `network` - The utxolib Network object from JavaScript + /// * `network` - The UtxolibNetwork object from JavaScript /// * `format` - Optional address format: "default" or "cashaddr" (only applicable for Bitcoin Cash and eCash) #[wasm_bindgen] pub fn from_output_script( @@ -156,8 +161,8 @@ impl UtxolibCompatNamespace { network: JsValue, format: Option, ) -> std::result::Result { - let network = - Network::from_js_value(&network).map_err(|e| JsValue::from_str(&e.to_string()))?; + let network = UtxolibNetwork::from_js_value(&network) + .map_err(|e| JsValue::from_str(&e.to_string()))?; let script_obj = Script::from_bytes(script); @@ -173,7 +178,7 @@ impl UtxolibCompatNamespace { /// /// # Arguments /// * `address` - The address string - /// * `network` - The utxolib Network object from JavaScript + /// * `network` - The UtxolibNetwork object from JavaScript /// * `format` - Optional address format (currently unused for decoding as all formats are accepted) #[wasm_bindgen] pub fn to_output_script( @@ -181,8 +186,8 @@ impl UtxolibCompatNamespace { network: JsValue, format: Option, ) -> std::result::Result, JsValue> { - let network = - Network::from_js_value(&network).map_err(|e| JsValue::from_str(&e.to_string()))?; + let network = UtxolibNetwork::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 { diff --git a/packages/wasm-utxo/src/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/mod.rs index 41c83e4..852cc5e 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/mod.rs @@ -12,10 +12,10 @@ pub use wallet_keys::*; pub use wallet_scripts::*; use wasm_bindgen::prelude::*; -use crate::address::networks::{AddressFormat}; +use crate::address::networks::AddressFormat; use crate::error::WasmMiniscriptError; use crate::try_from_js_value::TryFromJsValue; -use crate::utxolib_compat::Network; +use crate::utxolib_compat::UtxolibNetwork; #[wasm_bindgen] pub struct FixedScriptWalletNamespace; @@ -29,12 +29,17 @@ impl FixedScriptWalletNamespace { index: u32, network: JsValue, ) -> Result, WasmMiniscriptError> { - let network = Network::try_from_js_value(&network)?; + let network = UtxolibNetwork::try_from_js_value(&network)?; let chain = Chain::try_from(chain) .map_err(|e| WasmMiniscriptError::new(&format!("Invalid chain: {}", e)))?; let wallet_keys = RootWalletKeys::from_jsvalue(&keys)?; - let scripts = WalletScripts::from_wallet_keys(&wallet_keys, chain, index, &network.output_script_support())?; + let scripts = WalletScripts::from_wallet_keys( + &wallet_keys, + chain, + index, + &network.output_script_support(), + )?; Ok(scripts.output_script().to_bytes()) } @@ -46,11 +51,16 @@ impl FixedScriptWalletNamespace { network: JsValue, address_format: Option, ) -> Result { - let network = Network::try_from_js_value(&network)?; + let network = UtxolibNetwork::try_from_js_value(&network)?; let wallet_keys = RootWalletKeys::from_jsvalue(&keys)?; let chain = Chain::try_from(chain) .map_err(|e| WasmMiniscriptError::new(&format!("Invalid chain: {}", e)))?; - let scripts = WalletScripts::from_wallet_keys(&wallet_keys, chain, index, &network.output_script_support())?; + let scripts = WalletScripts::from_wallet_keys( + &wallet_keys, + chain, + index, + &network.output_script_support(), + )?; let script = scripts.output_script(); let address_format = AddressFormat::from_optional_str(address_format.as_deref()) .map_err(|e| WasmMiniscriptError::new(&format!("Invalid address format: {}", e)))?; diff --git a/packages/wasm-utxo/src/try_from_js_value.rs b/packages/wasm-utxo/src/try_from_js_value.rs index f5f0294..ac4a041 100644 --- a/packages/wasm-utxo/src/try_from_js_value.rs +++ b/packages/wasm-utxo/src/try_from_js_value.rs @@ -1,4 +1,4 @@ -use crate::address::utxolib_compat::{CashAddr, Network}; +use crate::address::utxolib_compat::{CashAddr, UtxolibNetwork}; use crate::error::WasmMiniscriptError; use wasm_bindgen::JsValue; @@ -119,14 +119,14 @@ pub(crate) fn get_buffer_field_vec( Ok(bytes) } -impl TryFromJsValue for Network { +impl TryFromJsValue for UtxolibNetwork { fn try_from_js_value(value: &JsValue) -> Result { let pub_key_hash = get_field(value, "pubKeyHash")?; let script_hash = get_field(value, "scriptHash")?; let bech32 = get_field(value, "bech32")?; let cash_addr = get_field(value, "cashAddr")?; - Ok(Network { + Ok(UtxolibNetwork { pub_key_hash, script_hash, cash_addr,