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..65641a2 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 */ @@ -11,19 +12,33 @@ 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); } /** * 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/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..c792225 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}; @@ -17,28 +17,48 @@ 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 { + 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 +/// 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)?; + // Handle cashaddr format if requested if matches!(format, AddressFormat::Cashaddr) { if let Some(ref cash_addr) = network.cash_addr { @@ -79,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}; @@ -133,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( @@ -141,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); @@ -158,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( @@ -166,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 f45c5bd..852cc5e 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/mod.rs @@ -15,7 +15,7 @@ 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; +use crate::utxolib_compat::UtxolibNetwork; #[wasm_bindgen] pub struct FixedScriptWalletNamespace; @@ -27,12 +27,19 @@ impl FixedScriptWalletNamespace { keys: JsValue, chain: u32, index: u32, + network: JsValue, ) -> Result, WasmMiniscriptError> { + 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); + let scripts = WalletScripts::from_wallet_keys( + &wallet_keys, + chain, + index, + &network.output_script_support(), + )?; Ok(scripts.output_script().to_bytes()) } @@ -42,17 +49,25 @@ impl FixedScriptWalletNamespace { chain: u32, index: u32, 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); + 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)))?; 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/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/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, diff --git a/packages/wasm-utxo/test/fixedScript/address.ts b/packages/wasm-utxo/test/fixedScript/address.ts index 2c4758f..5156af7 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,14 +22,29 @@ 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"); + 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); @@ -37,20 +53,65 @@ function runTest(network: utxolib.Network, derivationPrefixes?: Triple) 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, + index, + network, + addressFormat ?? "default", + ); + const wasmAddress = fixedScriptWallet.address( + rootWalletKeys, + chainCode, + index, + network, + addressFormat, ); - const utxolibAddress = getAddressUtxoLib(rootWalletKeys, chainCode, index, network); - const wasmAddress = fixedScriptWallet.address(rootWalletKeys, chainCode, index, network); assert.strictEqual(utxolibAddress, wasmAddress); } } }); + + 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)}`, + ); + } + }); + } }); } 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" }); + } });