From a665edb532e2d1db86261842d9136a29970be778 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Fri, 9 Jan 2026 13:59:48 +0100 Subject: [PATCH 1/2] fix(wasm-utxo): swap internal and external chain values for taproot The taproot chains had internal/external values swapped compared to other script types. This commit fixes the enum values to be consistent, making external chains have even values and internal chains have odd values. This is not a breaking change since the internal/external distinctions were not surfaced in the public API. Co-authored-by: llm-git Issue: BTC-2916 --- .../fixed_script_wallet/wallet_scripts/mod.rs | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) 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 8d598ce8..e04307fd 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 @@ -128,10 +128,10 @@ pub enum Chain { P2shP2wshInternal = 11, P2wshExternal = 20, P2wshInternal = 21, - P2trInternal = 30, - P2trExternal = 31, - P2trMusig2Internal = 40, - P2trMusig2External = 41, + P2trExternal = 30, + P2trInternal = 31, + P2trMusig2External = 40, + P2trMusig2Internal = 41, } /// Useful for iterating over enum values @@ -142,10 +142,10 @@ const ALL_CHAINS: [Chain; 10] = [ Chain::P2shP2wshInternal, Chain::P2wshExternal, Chain::P2wshInternal, - Chain::P2trInternal, Chain::P2trExternal, - Chain::P2trMusig2Internal, + Chain::P2trInternal, Chain::P2trMusig2External, + Chain::P2trMusig2Internal, ]; impl Chain { @@ -272,28 +272,28 @@ mod tests { assert_output_script( keys, chain, - "51203a81504b836967a69399fcf3822adfdb7d61061e42418f6aad0d473cbcc69b86", + "512093e5e3c8885a6f87b4449e1bffa3ba8a45a9ee634dc27408394c7d9b68f01adc", ); } Chain::P2trExternal => { assert_output_script( keys, chain, - "512093e5e3c8885a6f87b4449e1bffa3ba8a45a9ee634dc27408394c7d9b68f01adc", + "51203a81504b836967a69399fcf3822adfdb7d61061e42418f6aad0d473cbcc69b86", ); } Chain::P2trMusig2Internal => { assert_output_script( keys, chain, - "5120c7c4dd55b2bf3cd7ea5b27d3da521699ce761aa345523d8486f0336364957ef2", + "51202629eea5dbef6841160a0b752dedd4b8e206f046835ee944848679d6dea2ac2c", ); } Chain::P2trMusig2External => { assert_output_script( keys, chain, - "51202629eea5dbef6841160a0b752dedd4b8e206f046835ee944848679d6dea2ac2c", + "5120c7c4dd55b2bf3cd7ea5b27d3da521699ce761aa345523d8486f0336364957ef2", ); } } From 2c28d1157d998f0eb077c59c8dd10c1809bfe6b6 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Fri, 9 Jan 2026 14:05:14 +0100 Subject: [PATCH 2/2] feat(wasm-utxo): add supportsScriptType function Add `supportsScriptType` function to check if a given network supports a particular wallet script type (p2sh, p2wsh, p2tr, etc). This provides a simple API for frontends to determine script compatibility before attempting to create wallets. Major improvements: - Refactored script type handling to use a dedicated OutputScriptType enum - Separated script type from derivation chain/scope concerns - Added network compatibility checks for script types - Exposed JS API with proper TypeScript definitions Issue: BTC-2916 Co-authored-by: llm-git --- .../js/fixedScriptWallet/BitGoPsbt.ts | 12 +- .../wasm-utxo/js/fixedScriptWallet/index.ts | 36 +- .../js/fixedScriptWallet/scriptType.ts | 32 ++ packages/wasm-utxo/src/address/networks.rs | 10 + .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 25 +- .../bitgo_psbt/psbt_wallet_input.rs | 16 +- .../src/fixed_script_wallet/test_utils/mod.rs | 4 +- .../wallet_scripts/checkmultisig.rs | 21 +- .../fixed_script_wallet/wallet_scripts/mod.rs | 397 ++++++++++++------ .../wasm/fixed_script_wallet/dimensions.rs | 34 +- .../src/wasm/fixed_script_wallet/mod.rs | 23 + 11 files changed, 405 insertions(+), 205 deletions(-) create mode 100644 packages/wasm-utxo/js/fixedScriptWallet/scriptType.ts diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index 412d8f49..4f6e575c 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -5,20 +5,14 @@ import { type BIP32Arg, BIP32 } from "../bip32.js"; import { type ECPairArg, ECPair } from "../ecpair.js"; import type { UtxolibName } from "../utxolibCompat.js"; import type { CoinName } from "../coinName.js"; +import type { InputScriptType } from "./scriptType.js"; + +export type { InputScriptType }; export type NetworkName = UtxolibName | CoinName; export type ScriptId = { chain: number; index: number }; -export type InputScriptType = - | "p2shP2pk" - | "p2sh" - | "p2shP2wsh" - | "p2wsh" - | "p2trLegacy" - | "p2trMusig2ScriptPath" - | "p2trMusig2KeyPath"; - export type OutPoint = { txid: string; vout: number; diff --git a/packages/wasm-utxo/js/fixedScriptWallet/index.ts b/packages/wasm-utxo/js/fixedScriptWallet/index.ts index 62c02aef..6628d975 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/index.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/index.ts @@ -1,14 +1,17 @@ +import { FixedScriptWalletNamespace } from "../wasm/wasm_utxo.js"; +import type { CoinName } from "../coinName.js"; + export { RootWalletKeys, type WalletKeysArg, type IWalletKeys } from "./RootWalletKeys.js"; export { ReplayProtection, type ReplayProtectionArg } from "./ReplayProtection.js"; export { outputScript, address } from "./address.js"; export { Dimensions } from "./Dimensions.js"; +export { type OutputScriptType, type InputScriptType, type ScriptType } from "./scriptType.js"; // Bitcoin-like PSBT (for all non-Zcash networks) export { BitGoPsbt, type NetworkName, type ScriptId, - type InputScriptType, type ParsedInput, type ParsedOutput, type ParsedTransaction, @@ -26,3 +29,34 @@ export { type ZcashNetworkName, type CreateEmptyZcashOptions, } from "./ZcashBitGoPsbt.js"; + +import type { ScriptType } from "./scriptType.js"; + +/** + * Check if a network supports a given fixed-script wallet script type + * + * @param coin - Coin name (e.g., "btc", "ltc", "doge") + * @param scriptType - Output script type or input script type to check + * @returns `true` if the network supports the script type, `false` otherwise + * + * @example + * ```typescript + * // Bitcoin supports all script types + * supportsScriptType("btc", "p2tr"); // true + * + * // Litecoin supports segwit but not taproot + * supportsScriptType("ltc", "p2wsh"); // true + * supportsScriptType("ltc", "p2tr"); // false + * + * // Dogecoin only supports legacy scripts + * supportsScriptType("doge", "p2sh"); // true + * supportsScriptType("doge", "p2wsh"); // false + * + * // Also works with input script types + * supportsScriptType("btc", "p2trMusig2KeyPath"); // true + * supportsScriptType("doge", "p2trLegacy"); // false + * ``` + */ +export function supportsScriptType(coin: CoinName, scriptType: ScriptType): boolean { + return FixedScriptWalletNamespace.supports_script_type(coin, scriptType); +} diff --git a/packages/wasm-utxo/js/fixedScriptWallet/scriptType.ts b/packages/wasm-utxo/js/fixedScriptWallet/scriptType.ts new file mode 100644 index 00000000..52a14811 --- /dev/null +++ b/packages/wasm-utxo/js/fixedScriptWallet/scriptType.ts @@ -0,0 +1,32 @@ +/** + * Fixed-script wallet output script types (2-of-3 multisig) + * + * This type represents the abstract script type, independent of chain (external/internal). + * Use this for checking network support or when you need the script type without derivation info. + */ +export type OutputScriptType = + | "p2sh" + | "p2shP2wsh" + | "p2wsh" + | "p2tr" // alias for p2trLegacy + | "p2trLegacy" + | "p2trMusig2"; + +/** + * Input script types for fixed-script wallets + * + * These are more specific than output types and include single-sig and taproot variants. + */ +export type InputScriptType = + | "p2shP2pk" + | "p2sh" + | "p2shP2wsh" + | "p2wsh" + | "p2trLegacy" + | "p2trMusig2ScriptPath" + | "p2trMusig2KeyPath"; + +/** + * Union of all script types that can be checked for network support + */ +export type ScriptType = OutputScriptType | InputScriptType; diff --git a/packages/wasm-utxo/src/address/networks.rs b/packages/wasm-utxo/src/address/networks.rs index 5d6429de..cef4f1c4 100644 --- a/packages/wasm-utxo/src/address/networks.rs +++ b/packages/wasm-utxo/src/address/networks.rs @@ -13,6 +13,7 @@ use super::{ ZCASH_TEST, }; use crate::bitcoin::Script; +use crate::fixed_script_wallet::wallet_scripts::OutputScriptType; use crate::networks::Network; use miniscript::bitcoin::WitnessVersion; @@ -120,6 +121,15 @@ impl OutputScriptSupport { } Ok(()) } + + /// Check if the network supports a given fixed-script wallet script type + pub fn supports_script_type(&self, script_type: OutputScriptType) -> bool { + match script_type { + OutputScriptType::P2sh => true, // all networks support legacy scripts + OutputScriptType::P2shP2wsh | OutputScriptType::P2wsh => self.segwit, + OutputScriptType::P2trLegacy | OutputScriptType::P2trMusig2 => self.taproot, + } + } } impl Network { diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs index 5e775fd3..f2b2d457 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs @@ -184,16 +184,13 @@ fn get_default_sighash_type( network: Network, chain: crate::fixed_script_wallet::wallet_scripts::Chain, ) -> miniscript::bitcoin::psbt::PsbtSighashType { - use crate::fixed_script_wallet::wallet_scripts::Chain; + use crate::fixed_script_wallet::wallet_scripts::OutputScriptType; use miniscript::bitcoin::sighash::{EcdsaSighashType, TapSighashType}; // For taproot, always use Default if matches!( - chain, - Chain::P2trInternal - | Chain::P2trExternal - | Chain::P2trMusig2Internal - | Chain::P2trMusig2External + chain.script_type, + OutputScriptType::P2trLegacy | OutputScriptType::P2trMusig2 ) { return TapSighashType::Default.into(); } @@ -758,7 +755,7 @@ impl BitGoPsbt { options: WalletInputOptions, ) -> Result { use crate::fixed_script_wallet::to_pub_triple; - use crate::fixed_script_wallet::wallet_scripts::{Chain, WalletScripts}; + use crate::fixed_script_wallet::wallet_scripts::{Chain, OutputScriptType, WalletScripts}; use miniscript::bitcoin::psbt::Input; use miniscript::bitcoin::taproot::{LeafVersion, TapLeafHash}; use miniscript::bitcoin::{transaction::Sequence, Amount, OutPoint, TxIn, TxOut}; @@ -799,18 +796,8 @@ impl BitGoPsbt { // Create the PSBT input let mut psbt_input = Input::default(); - // Determine if segwit based on chain type - let is_segwit = matches!( - chain_enum, - Chain::P2shP2wshExternal - | Chain::P2shP2wshInternal - | Chain::P2wshExternal - | Chain::P2wshInternal - | Chain::P2trInternal - | Chain::P2trExternal - | Chain::P2trMusig2Internal - | Chain::P2trMusig2External - ); + // Determine if segwit based on chain type (all types except P2sh are segwit) + let is_segwit = chain_enum.script_type != OutputScriptType::P2sh; if let (false, Some(tx_bytes)) = (is_segwit, options.prev_tx) { // Non-segwit with prev_tx: use non_witness_utxo diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs index b4b03a1a..801027d9 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs @@ -4,7 +4,9 @@ use miniscript::bitcoin::secp256k1::{self, PublicKey}; use miniscript::bitcoin::{OutPoint, ScriptBuf, TapLeafHash, XOnlyPublicKey}; use crate::bitcoin::bip32::KeySource; -use crate::fixed_script_wallet::{Chain, ReplayProtection, RootWalletKeys, WalletScripts}; +use crate::fixed_script_wallet::{ + Chain, OutputScriptType, ReplayProtection, RootWalletKeys, WalletScripts, +}; use crate::Network; pub type Bip32DerivationMap = std::collections::BTreeMap; @@ -655,12 +657,12 @@ pub enum InputScriptType { impl InputScriptType { pub fn from_script_id(script_id: ScriptId, psbt_input: &Input) -> Result { let chain = Chain::try_from(script_id.chain).map_err(|e| e.to_string())?; - match chain { - Chain::P2shExternal | Chain::P2shInternal => Ok(InputScriptType::P2sh), - Chain::P2shP2wshExternal | Chain::P2shP2wshInternal => Ok(InputScriptType::P2shP2wsh), - Chain::P2wshExternal | Chain::P2wshInternal => Ok(InputScriptType::P2wsh), - Chain::P2trInternal | Chain::P2trExternal => Ok(InputScriptType::P2trLegacy), - Chain::P2trMusig2Internal | Chain::P2trMusig2External => { + match chain.script_type { + OutputScriptType::P2sh => Ok(InputScriptType::P2sh), + OutputScriptType::P2shP2wsh => Ok(InputScriptType::P2shP2wsh), + OutputScriptType::P2wsh => Ok(InputScriptType::P2wsh), + OutputScriptType::P2trLegacy => Ok(InputScriptType::P2trLegacy), + OutputScriptType::P2trMusig2 => { // check if tap_script_sigs or tap_scripts are set if !psbt_input.tap_script_sigs.is_empty() || !psbt_input.tap_scripts.is_empty() { Ok(InputScriptType::P2trMusig2ScriptPath) 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 7fc102ee..9323d3a2 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 @@ -4,7 +4,7 @@ pub mod fixtures; pub mod psbt_compare; use super::wallet_keys::XpubTriple; -use super::wallet_scripts::{Chain, WalletScripts}; +use super::wallet_scripts::{Chain, OutputScriptType, Scope, 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}; @@ -38,7 +38,7 @@ 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, + Chain::new(OutputScriptType::P2wsh, Scope::External), 0, &Network::Bitcoin.output_script_support(), ) diff --git a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/checkmultisig.rs b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/checkmultisig.rs index 5b35528a..0adaa696 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/checkmultisig.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/checkmultisig.rs @@ -100,15 +100,12 @@ mod tests { use crate::bitcoin::blockdata::script::Builder; use crate::fixed_script_wallet::wallet_keys::tests::get_test_wallet_keys; use crate::fixed_script_wallet::wallet_keys::to_pub_triple; - use crate::fixed_script_wallet::wallet_scripts::Chain; #[test] fn test_parse_multisig_script_2_of_3_valid() { // Get test keys let wallet_keys = get_test_wallet_keys("test_parse"); - let derived_keys = wallet_keys - .derive_for_chain_and_index(Chain::P2shExternal as u32, 0) - .unwrap(); + let derived_keys = wallet_keys.derive_for_chain_and_index(0, 0).unwrap(); let pub_triple = to_pub_triple(&derived_keys); // Build a valid 2-of-3 multisig script @@ -126,9 +123,7 @@ mod tests { // Test multiple different key sets for seed in ["seed1", "seed2", "seed3"] { let wallet_keys = get_test_wallet_keys(seed); - let derived_keys = wallet_keys - .derive_for_chain_and_index(Chain::P2shExternal as u32, 42) - .unwrap(); + let derived_keys = wallet_keys.derive_for_chain_and_index(0, 42).unwrap(); let original_keys = to_pub_triple(&derived_keys); // Build script from keys @@ -168,9 +163,7 @@ mod tests { fn test_parse_multisig_script_2_of_3_wrong_quorum() { // Create a valid key for testing let wallet_keys = get_test_wallet_keys("test_wrong_quorum"); - let derived_keys = wallet_keys - .derive_for_chain_and_index(Chain::P2shExternal as u32, 0) - .unwrap(); + let derived_keys = wallet_keys.derive_for_chain_and_index(0, 0).unwrap(); let pub_triple = to_pub_triple(&derived_keys); // Build script with wrong quorum (OP_1 instead of OP_2) @@ -194,9 +187,7 @@ mod tests { fn test_parse_multisig_script_2_of_3_wrong_total() { // Create a valid key for testing let wallet_keys = get_test_wallet_keys("test_wrong_total"); - let derived_keys = wallet_keys - .derive_for_chain_and_index(Chain::P2shExternal as u32, 0) - .unwrap(); + let derived_keys = wallet_keys.derive_for_chain_and_index(0, 0).unwrap(); let pub_triple = to_pub_triple(&derived_keys); // Build script with wrong total (OP_4 instead of OP_3) @@ -220,9 +211,7 @@ mod tests { fn test_parse_multisig_script_2_of_3_missing_checkmultisig() { // Create a valid key for testing let wallet_keys = get_test_wallet_keys("test_missing_checkmultisig"); - let derived_keys = wallet_keys - .derive_for_chain_and_index(Chain::P2shExternal as u32, 0) - .unwrap(); + let derived_keys = wallet_keys.derive_for_chain_and_index(0, 0).unwrap(); let pub_triple = to_pub_triple(&derived_keys); // Build script without OP_CHECKMULTISIG 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 e04307fd..89bce8b4 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 @@ -62,15 +62,15 @@ impl WalletScripts { chain: Chain, script_support: &OutputScriptSupport, ) -> Result { - match chain { - Chain::P2shExternal | Chain::P2shInternal => { + match chain.script_type { + OutputScriptType::P2sh => { script_support.assert_legacy()?; let script = build_multisig_script_2_of_3(keys); Ok(WalletScripts::P2sh(ScriptP2sh { redeem_script: script, })) } - Chain::P2shP2wshExternal | Chain::P2shP2wshInternal => { + OutputScriptType::P2shP2wsh => { script_support.assert_segwit()?; let script = build_multisig_script_2_of_3(keys); Ok(WalletScripts::P2shP2wsh(ScriptP2shP2wsh { @@ -78,18 +78,18 @@ impl WalletScripts { witness_script: script, })) } - Chain::P2wshExternal | Chain::P2wshInternal => { + OutputScriptType::P2wsh => { script_support.assert_segwit()?; let script = build_multisig_script_2_of_3(keys); Ok(WalletScripts::P2wsh(ScriptP2wsh { witness_script: script, })) } - Chain::P2trInternal | Chain::P2trExternal => { + OutputScriptType::P2trLegacy => { script_support.assert_taproot()?; Ok(WalletScripts::P2trLegacy(ScriptP2tr::new(keys, false))) } - Chain::P2trMusig2Internal | Chain::P2trMusig2External => { + OutputScriptType::P2trMusig2 => { script_support.assert_taproot()?; Ok(WalletScripts::P2trMusig2(ScriptP2tr::new(keys, true))) } @@ -103,7 +103,7 @@ impl WalletScripts { script_support: &OutputScriptSupport, ) -> Result { let derived_keys = wallet_keys - .derive_for_chain_and_index(chain as u32, index) + .derive_for_chain_and_index(chain.value(), index) .unwrap(); WalletScripts::new(&to_pub_triple(&derived_keys), chain, script_support) } @@ -119,39 +119,45 @@ impl WalletScripts { } } -/// BitGo-Defined mappings between derivation path component and script type +/// Whether a chain is for receiving (external) or change (internal) addresses. #[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] -pub enum Chain { - P2shExternal = 0, - P2shInternal = 1, - P2shP2wshExternal = 10, - P2shP2wshInternal = 11, - P2wshExternal = 20, - P2wshInternal = 21, - P2trExternal = 30, - P2trInternal = 31, - P2trMusig2External = 40, - P2trMusig2Internal = 41, +pub enum Scope { + /// External chains are for receiving addresses (even chain values: 0, 10, 20, 30, 40). + External, + /// Internal chains are for change addresses (odd chain values: 1, 11, 21, 31, 41). + Internal, } -/// Useful for iterating over enum values -const ALL_CHAINS: [Chain; 10] = [ - Chain::P2shExternal, - Chain::P2shInternal, - Chain::P2shP2wshExternal, - Chain::P2shP2wshInternal, - Chain::P2wshExternal, - Chain::P2wshInternal, - Chain::P2trExternal, - Chain::P2trInternal, - Chain::P2trMusig2External, - Chain::P2trMusig2Internal, -]; +/// BitGo-Defined mappings between derivation path component and script type. +/// +/// A Chain combines an `OutputScriptType` with a `Scope` (external/internal). +/// The chain value is used in derivation paths: `m/0/0/{chain}/{index}`. +/// +/// Chain values are normalized: external = base, internal = base + 1. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub struct Chain { + pub script_type: OutputScriptType, + pub scope: Scope, +} impl Chain { - #[allow(dead_code)] - pub fn all() -> &'static [Chain; 10] { - &ALL_CHAINS + /// Create a new Chain from script type and scope. + pub const fn new(script_type: OutputScriptType, scope: Scope) -> Self { + Self { script_type, scope } + } + + /// Get the u32 chain value for derivation paths. + pub const fn value(&self) -> u32 { + (match self.script_type { + OutputScriptType::P2sh => 0, + OutputScriptType::P2shP2wsh => 10, + OutputScriptType::P2wsh => 20, + OutputScriptType::P2trLegacy => 30, + OutputScriptType::P2trMusig2 => 40, + }) + match self.scope { + Scope::External => 0, + Scope::Internal => 1, + } } } @@ -159,12 +165,20 @@ impl TryFrom for Chain { type Error = String; fn try_from(value: u32) -> Result { - for chain in &ALL_CHAINS { - if *chain as u32 == value { - return Ok(*chain); - } - } - Err(format!("no chain for {}", value)) + let (script_type, scope) = match value { + 0 => (OutputScriptType::P2sh, Scope::External), + 1 => (OutputScriptType::P2sh, Scope::Internal), + 10 => (OutputScriptType::P2shP2wsh, Scope::External), + 11 => (OutputScriptType::P2shP2wsh, Scope::Internal), + 20 => (OutputScriptType::P2wsh, Scope::External), + 21 => (OutputScriptType::P2wsh, Scope::Internal), + 30 => (OutputScriptType::P2trLegacy, Scope::External), + 31 => (OutputScriptType::P2trLegacy, Scope::Internal), + 40 => (OutputScriptType::P2trMusig2, Scope::External), + 41 => (OutputScriptType::P2trMusig2, Scope::Internal), + _ => return Err(format!("no chain for {}", value)), + }; + Ok(Chain::new(script_type, scope)) } } @@ -177,6 +191,85 @@ impl FromStr for Chain { } } +/// Fixed-script wallet script types (2-of-3 multisig) +/// +/// This enum represents the abstract script type, independent of chain (external/internal). +/// Use this for checking network support or when you need the script type without derivation info. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +pub enum OutputScriptType { + /// Legacy Pay-To-Script-Hash (chains 0, 1) + P2sh, + /// Wrapped-Segwit Pay-To-Script-Hash (chains 10, 11) + P2shP2wsh, + /// Native Segwit Pay-To-Witness-Script-Hash (chains 20, 21) + P2wsh, + /// Legacy Taproot, script-path only (chains 30, 31) + P2trLegacy, + /// Taproot with MuSig2 key-path support (chains 40, 41) + P2trMusig2, +} + +/// All OutputScriptType variants for iteration. +const ALL_SCRIPT_TYPES: [OutputScriptType; 5] = [ + OutputScriptType::P2sh, + OutputScriptType::P2shP2wsh, + OutputScriptType::P2wsh, + OutputScriptType::P2trLegacy, + OutputScriptType::P2trMusig2, +]; + +impl FromStr for OutputScriptType { + type Err = String; + + /// Parse a script type string into an OutputScriptType. + /// + /// Accepts both output script types and input script types: + /// - Output types: "p2sh", "p2shP2wsh", "p2wsh", "p2tr"/"p2trLegacy", "p2trMusig2" + /// - Input types: "p2shP2pk" (→ P2sh), "p2trMusig2ScriptPath"/"p2trMusig2KeyPath" (→ P2trMusig2) + fn from_str(s: &str) -> Result { + match s { + // Output script types + "p2sh" => Ok(OutputScriptType::P2sh), + "p2shP2wsh" => Ok(OutputScriptType::P2shP2wsh), + "p2wsh" => Ok(OutputScriptType::P2wsh), + // "p2tr" is kept as alias for backwards compatibility + "p2tr" | "p2trLegacy" => Ok(OutputScriptType::P2trLegacy), + "p2trMusig2" => Ok(OutputScriptType::P2trMusig2), + // Input script types (normalized to output types) + "p2shP2pk" => Ok(OutputScriptType::P2sh), + "p2trMusig2ScriptPath" | "p2trMusig2KeyPath" => Ok(OutputScriptType::P2trMusig2), + _ => Err(format!( + "Unknown script type '{}'. Expected: p2sh, p2shP2wsh, p2wsh, p2trLegacy, p2trMusig2", + s + )), + } + } +} + +impl OutputScriptType { + /// Returns all possible OutputScriptType values. + pub fn all() -> &'static [OutputScriptType; 5] { + &ALL_SCRIPT_TYPES + } + + /// Get the string representation of the script type + pub fn as_str(&self) -> &'static str { + match self { + OutputScriptType::P2sh => "p2sh", + OutputScriptType::P2shP2wsh => "p2shP2wsh", + OutputScriptType::P2wsh => "p2wsh", + OutputScriptType::P2trLegacy => "p2trLegacy", + OutputScriptType::P2trMusig2 => "p2trMusig2", + } + } +} + +impl std::fmt::Display for OutputScriptType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + /// Return derived WalletKeys. All keys are derived with the same path. #[allow(dead_code)] pub fn derive_xpubs_with_path( @@ -200,7 +293,7 @@ pub fn derive_xpubs( let p = DerivationPath::from_str("m/0/0") .unwrap() .child(ChildNumber::Normal { - index: chain as u32, + index: chain.value(), }) .child(ChildNumber::Normal { index }); derive_xpubs_with_path(xpubs, ctx, p) @@ -212,6 +305,19 @@ mod tests { use crate::fixed_script_wallet::wallet_keys::tests::get_test_wallet_keys; use crate::Network; + const ALL_CHAINS: [Chain; 10] = [ + Chain::new(OutputScriptType::P2sh, Scope::External), + Chain::new(OutputScriptType::P2sh, Scope::Internal), + Chain::new(OutputScriptType::P2shP2wsh, Scope::External), + Chain::new(OutputScriptType::P2shP2wsh, Scope::Internal), + Chain::new(OutputScriptType::P2wsh, Scope::External), + Chain::new(OutputScriptType::P2wsh, Scope::Internal), + Chain::new(OutputScriptType::P2trLegacy, Scope::External), + Chain::new(OutputScriptType::P2trLegacy, Scope::Internal), + Chain::new(OutputScriptType::P2trMusig2, Scope::External), + Chain::new(OutputScriptType::P2trMusig2, Scope::Internal), + ]; + fn assert_output_script(keys: &RootWalletKeys, chain: Chain, expected_script: &str) { let scripts = WalletScripts::from_wallet_keys( keys, @@ -225,84 +331,40 @@ mod tests { } fn test_build_multisig_chain_with(keys: &RootWalletKeys, chain: Chain) { - match chain { - Chain::P2shExternal => { - assert_output_script( - keys, - chain, - "a914999a8eb861e3fabae1efe4fb16ff4752e1f5976687", - ); - } - Chain::P2shInternal => { - assert_output_script( - keys, - chain, - "a914487ca5843f23b9f3b85a00136bec647846d179ab87", - ); - } - Chain::P2shP2wshExternal => { - assert_output_script( - keys, - chain, - "a9141219b6d9430fffb8de14f14969a5c07172c4613b87", - ); - } - Chain::P2shP2wshInternal => { - assert_output_script( - keys, - chain, - "a914cbfab1a5a25afab05ff420bd9dd0958c6f1a7a2f87", - ); - } - Chain::P2wshExternal => { - assert_output_script( - keys, - chain, - "0020ce670e65fd69ef2eb1aa6087643a18ae5bff198ca20ef26da546e85962386c76", - ); + use OutputScriptType::*; + use Scope::*; + + let expected = match (chain.script_type, chain.scope) { + (P2sh, External) => "a914999a8eb861e3fabae1efe4fb16ff4752e1f5976687", + (P2sh, Internal) => "a914487ca5843f23b9f3b85a00136bec647846d179ab87", + (P2shP2wsh, External) => "a9141219b6d9430fffb8de14f14969a5c07172c4613b87", + (P2shP2wsh, Internal) => "a914cbfab1a5a25afab05ff420bd9dd0958c6f1a7a2f87", + (P2wsh, External) => { + "0020ce670e65fd69ef2eb1aa6087643a18ae5bff198ca20ef26da546e85962386c76" } - Chain::P2wshInternal => { - assert_output_script( - keys, - chain, - "00209cca08a252f9846a1417afbe46ed96bf09d5ec6d25f0effb7d841188d5992b7c", - ); + (P2wsh, Internal) => { + "00209cca08a252f9846a1417afbe46ed96bf09d5ec6d25f0effb7d841188d5992b7c" } - Chain::P2trInternal => { - assert_output_script( - keys, - chain, - "512093e5e3c8885a6f87b4449e1bffa3ba8a45a9ee634dc27408394c7d9b68f01adc", - ); + (P2trLegacy, External) => { + "51203a81504b836967a69399fcf3822adfdb7d61061e42418f6aad0d473cbcc69b86" } - Chain::P2trExternal => { - assert_output_script( - keys, - chain, - "51203a81504b836967a69399fcf3822adfdb7d61061e42418f6aad0d473cbcc69b86", - ); + (P2trLegacy, Internal) => { + "512093e5e3c8885a6f87b4449e1bffa3ba8a45a9ee634dc27408394c7d9b68f01adc" } - Chain::P2trMusig2Internal => { - assert_output_script( - keys, - chain, - "51202629eea5dbef6841160a0b752dedd4b8e206f046835ee944848679d6dea2ac2c", - ); + (P2trMusig2, External) => { + "5120c7c4dd55b2bf3cd7ea5b27d3da521699ce761aa345523d8486f0336364957ef2" } - Chain::P2trMusig2External => { - assert_output_script( - keys, - chain, - "5120c7c4dd55b2bf3cd7ea5b27d3da521699ce761aa345523d8486f0336364957ef2", - ); + (P2trMusig2, Internal) => { + "51202629eea5dbef6841160a0b752dedd4b8e206f046835ee944848679d6dea2ac2c" } - } + }; + assert_output_script(keys, chain, expected); } #[test] fn test_build_multisig_chain() { let keys = get_test_wallet_keys("lol"); - for chain in Chain::all() { + for chain in &ALL_CHAINS { test_build_multisig_chain_with(&keys, *chain); } } @@ -317,16 +379,27 @@ mod tests { taproot: false, }; - let result = - WalletScripts::from_wallet_keys(&keys, Chain::P2wshExternal, 0, &no_segwit_support); + use OutputScriptType::*; + use Scope::*; + + let result = WalletScripts::from_wallet_keys( + &keys, + Chain::new(P2wsh, External), + 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); + let result = WalletScripts::from_wallet_keys( + &keys, + Chain::new(P2shP2wsh, External), + 0, + &no_segwit_support, + ); assert!(result.is_err()); assert!(result .unwrap_err() @@ -339,8 +412,12 @@ mod tests { taproot: false, }; - let result = - WalletScripts::from_wallet_keys(&keys, Chain::P2trExternal, 0, &no_taproot_support); + let result = WalletScripts::from_wallet_keys( + &keys, + Chain::new(P2trLegacy, External), + 0, + &no_taproot_support, + ); assert!(result.is_err()); assert!(result .unwrap_err() @@ -349,7 +426,7 @@ mod tests { let result = WalletScripts::from_wallet_keys( &keys, - Chain::P2trMusig2External, + Chain::new(P2trMusig2, External), 0, &no_taproot_support, ); @@ -360,14 +437,19 @@ mod tests { .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); + let result = WalletScripts::from_wallet_keys( + &keys, + Chain::new(P2sh, External), + 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); + let result = + WalletScripts::from_wallet_keys(&keys, Chain::new(P2wsh, External), 0, &doge_support); assert!(result.is_err()); assert!(result .unwrap_err() @@ -376,7 +458,12 @@ mod tests { // 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); + let result = WalletScripts::from_wallet_keys( + &keys, + Chain::new(P2trLegacy, External), + 0, + <c_support, + ); assert!(result.is_err()); assert!(result .unwrap_err() @@ -384,23 +471,73 @@ mod tests { .contains("Network does not support taproot")); // Litecoin should support segwit scripts - let result = WalletScripts::from_wallet_keys(&keys, Chain::P2wshExternal, 0, <c_support); + let result = + WalletScripts::from_wallet_keys(&keys, Chain::new(P2wsh, External), 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::new(P2sh, External), + 0, + &btc_support + ) + .is_ok()); + assert!(WalletScripts::from_wallet_keys( + &keys, + Chain::new(P2wsh, External), + 0, + &btc_support + ) + .is_ok()); + assert!(WalletScripts::from_wallet_keys( + &keys, + Chain::new(P2trLegacy, External), + 0, + &btc_support + ) + .is_ok()); + assert!(WalletScripts::from_wallet_keys( + &keys, + Chain::new(P2trMusig2, External), + 0, + &btc_support + ) + .is_ok()); + } + + #[test] + fn test_output_script_type_from_str() { + use OutputScriptType::*; + + // Output script types + assert_eq!(OutputScriptType::from_str("p2sh").unwrap(), P2sh); + assert_eq!(OutputScriptType::from_str("p2shP2wsh").unwrap(), P2shP2wsh); + assert_eq!(OutputScriptType::from_str("p2wsh").unwrap(), P2wsh); + assert_eq!(OutputScriptType::from_str("p2tr").unwrap(), P2trLegacy); + assert_eq!( + OutputScriptType::from_str("p2trLegacy").unwrap(), + P2trLegacy ); - assert!( - WalletScripts::from_wallet_keys(&keys, Chain::P2wshExternal, 0, &btc_support).is_ok() + assert_eq!( + OutputScriptType::from_str("p2trMusig2").unwrap(), + P2trMusig2 ); - assert!( - WalletScripts::from_wallet_keys(&keys, Chain::P2trExternal, 0, &btc_support).is_ok() + + // Input script types (normalized to output types) + assert_eq!(OutputScriptType::from_str("p2shP2pk").unwrap(), P2sh); + assert_eq!( + OutputScriptType::from_str("p2trMusig2ScriptPath").unwrap(), + P2trMusig2 ); - assert!( - WalletScripts::from_wallet_keys(&keys, Chain::P2trMusig2External, 0, &btc_support) - .is_ok() + assert_eq!( + OutputScriptType::from_str("p2trMusig2KeyPath").unwrap(), + P2trMusig2 ); + + // Invalid script types + assert!(OutputScriptType::from_str("invalid").is_err()); + assert!(OutputScriptType::from_str("p2pkh").is_err()); } } diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/dimensions.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/dimensions.rs index 3705fd82..e06c62a7 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/dimensions.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/dimensions.rs @@ -7,7 +7,7 @@ use crate::error::WasmUtxoError; use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::{ parse_shared_chain_and_index, InputScriptType, }; -use crate::fixed_script_wallet::wallet_scripts::Chain; +use crate::fixed_script_wallet::wallet_scripts::{Chain, OutputScriptType}; use miniscript::bitcoin::VarInt; use wasm_bindgen::prelude::*; @@ -230,17 +230,11 @@ fn get_input_weights_for_chain( ) -> Result { let chain_enum = Chain::try_from(chain).map_err(|e| e.to_string())?; - match chain_enum { - Chain::P2shExternal | Chain::P2shInternal => { - Ok(get_input_weights_for_type(InputScriptType::P2sh)) - } - Chain::P2shP2wshExternal | Chain::P2shP2wshInternal => { - Ok(get_input_weights_for_type(InputScriptType::P2shP2wsh)) - } - Chain::P2wshExternal | Chain::P2wshInternal => { - Ok(get_input_weights_for_type(InputScriptType::P2wsh)) - } - Chain::P2trExternal | Chain::P2trInternal => { + match chain_enum.script_type { + OutputScriptType::P2sh => Ok(get_input_weights_for_type(InputScriptType::P2sh)), + OutputScriptType::P2shP2wsh => Ok(get_input_weights_for_type(InputScriptType::P2shP2wsh)), + OutputScriptType::P2wsh => Ok(get_input_weights_for_type(InputScriptType::P2wsh)), + OutputScriptType::P2trLegacy => { // Legacy p2tr - always script path // user+bitgo = level 1, user+backup = level 2 let is_recovery = cosigner == Some("backup"); @@ -253,7 +247,7 @@ fn get_input_weights_for_chain( is_segwit: true, }) } - Chain::P2trMusig2External | Chain::P2trMusig2Internal => { + OutputScriptType::P2trMusig2 => { // p2trMusig2 - keypath for user+bitgo, scriptpath for user+backup let is_recovery = cosigner == Some("backup"); if is_recovery { @@ -356,14 +350,12 @@ impl WasmDimensions { })?; // For p2trMusig2, check if it's keypath or scriptpath - let script_type = match chain_enum { - Chain::P2shExternal | Chain::P2shInternal => InputScriptType::P2sh, - Chain::P2shP2wshExternal | Chain::P2shP2wshInternal => { - InputScriptType::P2shP2wsh - } - Chain::P2wshExternal | Chain::P2wshInternal => InputScriptType::P2wsh, - Chain::P2trExternal | Chain::P2trInternal => InputScriptType::P2trLegacy, - Chain::P2trMusig2External | Chain::P2trMusig2Internal => { + let script_type = match chain_enum.script_type { + OutputScriptType::P2sh => InputScriptType::P2sh, + OutputScriptType::P2shP2wsh => InputScriptType::P2shP2wsh, + OutputScriptType::P2wsh => InputScriptType::P2wsh, + OutputScriptType::P2trLegacy => InputScriptType::P2trLegacy, + OutputScriptType::P2trMusig2 => { // Check if tap_scripts are populated to distinguish keypath/scriptpath if !psbt_input.tap_script_sigs.is_empty() || !psbt_input.tap_scripts.is_empty() diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs index bf56c7d9..b26f1451 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -3,11 +3,13 @@ mod dimensions; pub use dimensions::WasmDimensions; use std::collections::HashMap; +use std::str::FromStr; use wasm_bindgen::prelude::*; use wasm_bindgen::JsValue; use crate::address::networks::AddressFormat; use crate::error::WasmUtxoError; +use crate::fixed_script_wallet::wallet_scripts::OutputScriptType; use crate::fixed_script_wallet::{Chain, WalletScripts}; use crate::utxolib_compat::UtxolibNetwork; use crate::wasm::bip32::WasmBIP32; @@ -84,6 +86,27 @@ impl FixedScriptWalletNamespace { .map_err(|e| WasmUtxoError::new(&format!("Failed to generate address: {}", e)))?; Ok(address) } + + /// Check if a network supports a given fixed-script wallet script type + /// + /// # Arguments + /// * `coin` - Coin name (e.g., "btc", "ltc", "doge") + /// * `script_type` - Script type name: "p2sh", "p2shP2wsh", "p2wsh", "p2tr", "p2trMusig2" + /// + /// # Returns + /// `true` if the network supports the script type, `false` otherwise + /// + /// # Examples + /// - Bitcoin supports all script types (p2sh, p2shP2wsh, p2wsh, p2tr, p2trMusig2) + /// - Litecoin supports segwit but not taproot (p2sh, p2shP2wsh, p2wsh) + /// - Dogecoin only supports legacy scripts (p2sh) + #[wasm_bindgen] + pub fn supports_script_type(coin: &str, script_type: &str) -> Result { + let network = crate::networks::Network::from_coin_name(coin) + .ok_or_else(|| WasmUtxoError::new(&format!("Unknown coin: {}", coin)))?; + let st = OutputScriptType::from_str(script_type).map_err(|e| WasmUtxoError::new(&e))?; + Ok(network.output_script_support().supports_script_type(st)) + } } #[wasm_bindgen] pub struct BitGoPsbt {