From 795101517a4afbbcb1a4bf78f95496457054193f Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 21 Apr 2026 16:35:05 +0200 Subject: [PATCH 1/2] feat(wasm-utxo): export sighash and consensus branch getters Export `get_sighash_fork_id` and `get_zec_consensus_branch_id` from the bitgo_psbt module for external use. Add `as_str` method to `ScriptType` enum for string representation. Co-authored-by: llm-git --- .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 7 +++++-- .../bitgo_psbt/psbt_wallet_input.rs | 13 +++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) 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 f107ab210c0..1394ce1650d 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 @@ -17,8 +17,11 @@ pub mod zcash_psbt; use crate::Network; pub use dash_psbt::DashBitGoPsbt; use miniscript::bitcoin::{psbt::Psbt, secp256k1, CompressedPublicKey, Txid}; -pub use propkv::{find_kv, BitGoKeyValue, ProprietaryKeySubtype, WasmUtxoVersionInfo, BITGO}; -pub use sighash::validate_sighash_type; +pub use propkv::{ + find_kv, get_zec_consensus_branch_id, BitGoKeyValue, ProprietaryKeySubtype, + WasmUtxoVersionInfo, BITGO, +}; +pub use sighash::{get_sighash_fork_id, validate_sighash_type}; pub use zcash_psbt::{ decode_zcash_transaction_meta, ZcashBitGoPsbt, ZcashTransactionMeta, ZCASH_SAPLING_VERSION_GROUP_ID, 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 9d2a2b3643b..e62a3f811ca 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 @@ -803,6 +803,19 @@ impl InputScriptType { } } } + + pub fn as_str(self) -> &'static str { + match self { + Self::P2shP2pk => "p2shP2pk", + Self::P2sh => "p2sh", + Self::P2shP2wsh => "p2shP2wsh", + Self::P2wsh => "p2wsh", + Self::P2trLegacy => "p2trLegacy", + Self::P2trMusig2KeyPath => "p2trMusig2", + Self::P2trMusig2ScriptPath => "p2trMusig2Script", + Self::P2mr => "p2mr", + } + } } /// Parsed input from a PSBT transaction From 91f37a9b970cb1c1e13d7ea33166afbbd37e1fd1 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Mon, 4 May 2026 15:02:29 +0200 Subject: [PATCH 2/2] feat(wasm-utxo): refactor script derivation to use full paths Refactor derivation to use full BIP32 paths instead of separate chain and index parameters. Centralize chain/index path construction in `chain_index_path()` helper. Replace `from_chain_and_index()` with generic `derive_path()` that accepts any derivation path. Cache results using the last two Normal path components. Move `Chain`, `Scope`, and `ScriptId` types to new `script_id` module. Add `WalletOutputScript` type to encapsulate script type + derivation path matching. Implement `from_psbt()` method to match PSBT metadata against wallet keys. Add `derivationPath` field to `ParsedInput` and `ParsedOutput` types, containing full BIP32 path (e.g. "0/1"). Make `scriptId` nullable to indicate when derivation path is nonstandard or external. Update JSDoc comments to clarify field semantics. Issue: BTC-2650 Co-authored-by: llm-git --- .../js/fixedScriptWallet/BitGoPsbt.ts | 6 + packages/wasm-utxo/src/bip322/bitgo_psbt.rs | 15 +- .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 61 +-- .../bitgo_psbt/psbt_wallet_input.rs | 416 ++++-------------- .../bitgo_psbt/psbt_wallet_output.rs | 111 +---- .../wasm-utxo/src/fixed_script_wallet/mod.rs | 2 + .../src/fixed_script_wallet/script_id.rs | 111 +++++ .../src/fixed_script_wallet/test_utils/mod.rs | 10 +- .../src/fixed_script_wallet/wallet_keys.rs | 46 +- .../wallet_scripts/checkmultisig.rs | 11 +- .../wallet_scripts/checksigverify.rs | 3 +- .../fixed_script_wallet/wallet_scripts/mod.rs | 409 +++++++++-------- .../wasm/fixed_script_wallet/dimensions.rs | 3 +- .../src/wasm/fixed_script_wallet/mod.rs | 25 +- .../wasm-utxo/src/wasm/try_from_js_value.rs | 2 +- .../wasm-utxo/src/wasm/try_into_js_value.rs | 12 +- packages/wasm-utxo/src/wasm/wallet_keys.rs | 16 +- .../test/fixedScript/nonStandardPaths.ts | 178 ++++++++ 18 files changed, 717 insertions(+), 720 deletions(-) create mode 100644 packages/wasm-utxo/src/fixed_script_wallet/script_id.rs create mode 100644 packages/wasm-utxo/test/fixedScript/nonStandardPaths.ts diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index eb08aebb027..93a56b6d34f 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -36,17 +36,23 @@ export type ParsedInput = { address: string; script: Uint8Array; value: bigint; + /** Set only when the derivation path is chain-standard (chain code encodes script type per BitGo convention). */ scriptId: ScriptId | null; scriptType: InputScriptType; sequence: number; + /** Full BIP32 derivation path from the wallet xpub (e.g. "0/1"). Null for replay-protection inputs. */ + derivationPath: string | null; }; export type ParsedOutput = { address: string | null; script: Uint8Array; value: bigint; + /** Set only when the derivation path is chain-standard (chain code encodes script type per BitGo convention). */ scriptId: ScriptId | null; paygo: boolean; + /** Full BIP32 derivation path from the wallet xpub (e.g. "0/1"). Null for external outputs. */ + derivationPath: string | null; }; export type ParsedTransaction = { diff --git a/packages/wasm-utxo/src/bip322/bitgo_psbt.rs b/packages/wasm-utxo/src/bip322/bitgo_psbt.rs index 0385943c732..384bccb7f23 100644 --- a/packages/wasm-utxo/src/bip322/bitgo_psbt.rs +++ b/packages/wasm-utxo/src/bip322/bitgo_psbt.rs @@ -8,6 +8,7 @@ use crate::fixed_script_wallet::bitgo_psbt::{ create_bip32_derivation, create_tap_bip32_derivation, find_kv, BitGoKeyValue, BitGoPsbt, ProprietaryKeySubtype, }; +use crate::fixed_script_wallet::wallet_scripts::chain_index_path; use crate::fixed_script_wallet::wallet_scripts::{ build_multisig_script_2_of_3, build_p2tr_ns_script, ScriptP2mr, ScriptP2tr, }; @@ -88,8 +89,8 @@ pub fn add_bip322_input( let chain_enum = Chain::try_from(chain).map_err(|e| format!("Invalid chain: {}", e))?; let scripts = WalletScripts::from_wallet_keys( wallet_keys, - chain_enum, - index, + chain_enum.script_type, + &chain_index_path(chain, index), &network.output_script_support(), ) .map_err(|e| e.to_string())?; @@ -190,7 +191,7 @@ pub fn add_bip322_input( // Derive pubkeys let derived_keys = wallet_keys - .derive_for_chain_and_index(chain, index) + .derive_path(&chain_index_path(chain, index)) .map_err(|e| format!("Failed to derive keys: {}", e))?; let pub_triple = to_pub_triple(&derived_keys); @@ -335,8 +336,8 @@ pub fn verify_bip322_tx_input( let chain_enum = Chain::try_from(chain).map_err(|e| format!("Invalid chain: {}", e))?; let scripts = WalletScripts::from_wallet_keys( wallet_keys, - chain_enum, - index, + chain_enum.script_type, + &chain_index_path(chain, index), &network.output_script_support(), ) .map_err(|e| e.to_string())?; @@ -427,8 +428,8 @@ pub fn verify_bip322_psbt_input( let chain_enum = Chain::try_from(chain).map_err(|e| format!("Invalid chain: {}", e))?; let scripts = WalletScripts::from_wallet_keys( wallet_keys, - chain_enum, - index, + chain_enum.script_type, + &chain_index_path(chain, index), &network.output_script_support(), ) .map_err(|e| e.to_string())?; 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 1394ce1650d..9ce56529ed7 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 @@ -107,15 +107,16 @@ pub enum BitGoPsbt { } // Re-export types from submodules for convenience +pub use crate::fixed_script_wallet::{ScriptId, ScriptIdWithValue}; pub use psbt_wallet_input::{ - InputScriptType, ParsedInput, ReplayProtectionOptions, ScriptId, WalletInputOptions, + InputScriptType, ParsedInput, ReplayProtectionOptions, WalletInputOptions, }; pub use psbt_wallet_output::ParsedOutput; /// Describes a single input for `from_half_signed_legacy_transaction`. pub enum HydrationUnspentInput { /// A regular wallet input with derivation chain, index, and value. - Wallet(psbt_wallet_input::ScriptIdWithValue), + Wallet(ScriptIdWithValue), /// A P2SH-P2PK replay protection input. The caller provides the expected pubkey so it can be /// validated against the redeemScript embedded in the legacy transaction. ReplayProtection { @@ -187,7 +188,7 @@ impl std::error::Error for ParseTransactionError {} /// Get the default sighash type for a network and chain type fn get_default_sighash_type( network: Network, - chain: crate::fixed_script_wallet::wallet_scripts::Chain, + chain: crate::fixed_script_wallet::Chain, ) -> miniscript::bitcoin::psbt::PsbtSighashType { use crate::fixed_script_wallet::wallet_scripts::OutputScriptType; use miniscript::bitcoin::sighash::{EcdsaSighashType, TapSighashType}; @@ -506,7 +507,7 @@ impl BitGoPsbt { for (i, (tx_in, unspent)) in tx.input.iter().zip(unspents.iter()).enumerate() { match unspent { HydrationUnspentInput::Wallet(sv) => { - let script_id = psbt_wallet_input::ScriptId { + let script_id = ScriptId { chain: sv.chain, index: sv.index, }; @@ -897,11 +898,14 @@ impl BitGoPsbt { vout: u32, value: u64, wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, - script_id: psbt_wallet_input::ScriptId, + script_id: ScriptId, options: WalletInputOptions, ) -> Result<(), String> { use crate::fixed_script_wallet::to_pub_triple; - use crate::fixed_script_wallet::wallet_scripts::{Chain, OutputScriptType, WalletScripts}; + use crate::fixed_script_wallet::wallet_scripts::{ + chain_index_path, OutputScriptType, WalletScripts, + }; + use crate::fixed_script_wallet::Chain; use miniscript::bitcoin::psbt::Input; use miniscript::bitcoin::taproot::{LeafVersion, TapLeafHash}; use miniscript::bitcoin::{transaction::Sequence, Amount, OutPoint, TxIn, TxOut}; @@ -914,12 +918,12 @@ impl BitGoPsbt { let chain_enum = Chain::try_from(chain)?; let derived_keys = wallet_keys - .derive_for_chain_and_index(chain, derivation_index) + .derive_path(&chain_index_path(chain, derivation_index)) .map_err(|e| format!("Failed to derive keys: {}", e))?; let pub_triple = to_pub_triple(&derived_keys); let script_support = network.output_script_support(); - let scripts = WalletScripts::new(&pub_triple, chain_enum, &script_support) + let scripts = WalletScripts::new(&pub_triple, chain_enum.script_type, &script_support) .map_err(|e| format!("Failed to create wallet scripts: {}", e))?; let output_script = scripts.output_script(); @@ -1049,7 +1053,7 @@ impl BitGoPsbt { vout: u32, value: u64, wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, - script_id: psbt_wallet_input::ScriptId, + script_id: ScriptId, options: WalletInputOptions, ) -> Result { let network = self.network(); @@ -1073,7 +1077,7 @@ impl BitGoPsbt { vout: u32, value: u64, wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, - script_id: psbt_wallet_input::ScriptId, + script_id: ScriptId, options: WalletInputOptions, ) -> Result { let index = self.psbt().inputs.len(); @@ -1104,8 +1108,10 @@ impl BitGoPsbt { ) -> Result { use crate::fixed_script_wallet::to_pub_triple; use crate::fixed_script_wallet::wallet_scripts::{ - build_tap_tree_for_output, create_tap_bip32_derivation_for_output, Chain, WalletScripts, + build_tap_tree_for_output, chain_index_path, create_tap_bip32_derivation_for_output, + WalletScripts, }; + use crate::fixed_script_wallet::Chain; use miniscript::bitcoin::psbt::Output; use miniscript::bitcoin::{Amount, TxOut}; use std::convert::TryFrom; @@ -1116,12 +1122,12 @@ impl BitGoPsbt { let chain_enum = Chain::try_from(chain)?; let derived_keys = wallet_keys - .derive_for_chain_and_index(chain, derivation_index) + .derive_path(&chain_index_path(chain, derivation_index)) .map_err(|e| format!("Failed to derive keys: {}", e))?; let pub_triple = to_pub_triple(&derived_keys); let script_support = network.output_script_support(); - let scripts = WalletScripts::new(&pub_triple, chain_enum, &script_support) + let scripts = WalletScripts::new(&pub_triple, chain_enum.script_type, &script_support) .map_err(|e| format!("Failed to create wallet scripts: {}", e))?; let output_script = scripts.output_script(); @@ -2385,15 +2391,6 @@ impl BitGoPsbt { Ok(()) } - /// Parse inputs with wallet keys and replay protection - /// - /// # Arguments - /// - `wallet_keys`: The wallet's root keys for deriving scripts - /// - `replay_protection`: Scripts that are allowed as inputs without wallet validation - /// - /// # Returns - /// - `Ok(Vec)` with parsed inputs - /// - `Err(ParseTransactionError)` if input parsing fails fn parse_inputs( &self, wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, @@ -3207,6 +3204,7 @@ pub fn to_wallet_keys( use crate::fixed_script_wallet::RootWalletKeys; let inner_psbt = psbt.psbt(); + let network = psbt.network(); // Collect non-replay-protection inputs (those with derivation info) let wallet_inputs: Vec<_> = inner_psbt @@ -3233,9 +3231,15 @@ pub fn to_wallet_keys( tx_input.previous_output, ); match output_script { - Ok((script, _value)) => { - psbt_wallet_input::assert_wallet_input(&wallet_keys, psbt_input, script).is_ok() - } + Ok((script, _value)) => crate::fixed_script_wallet::WalletOutputScript::from_psbt( + &wallet_keys, + &psbt_input.bip32_derivation, + &psbt_input.tap_key_origins, + psbt_input.witness_script.is_some(), + script, + network, + ) + .is_ok_and(|o| o.is_some()), Err(_) => false, } }); @@ -3251,6 +3255,7 @@ pub fn to_wallet_keys( #[cfg(test)] mod tests { use super::*; + use crate::fixed_script_wallet::wallet_scripts::chain_index_path; use crate::fixed_script_wallet::Chain; use crate::fixed_script_wallet::RootWalletKeys; use crate::fixed_script_wallet::WalletScripts; @@ -3607,8 +3612,8 @@ mod tests { parse_fixture_paths(input_fixture).expect("Failed to parse fixture paths"); let scripts = WalletScripts::from_wallet_keys( &wallet_keys.to_root_wallet_keys(), - chain, - index, + chain.script_type, + &chain_index_path(chain.value(), index), &network.output_script_support(), ) .expect("Failed to create wallet scripts"); @@ -4220,7 +4225,7 @@ mod tests { format: fixtures::TxFormat, script_type: fixtures::ScriptType, ) -> Result<(), String> { - use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::ScriptIdWithValue; + use crate::fixed_script_wallet::ScriptIdWithValue; let is_p2ms = matches!( script_type, 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 e62a3f811ca..bda28a9f3c5 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 @@ -1,50 +1,17 @@ -use miniscript::bitcoin::bip32::{ChildNumber, DerivationPath}; +use miniscript::bitcoin::bip32::DerivationPath; use miniscript::bitcoin::psbt::{Input, Psbt}; use miniscript::bitcoin::secp256k1::{self, PublicKey}; use miniscript::bitcoin::{OutPoint, ScriptBuf, TapLeafHash, XOnlyPublicKey}; use crate::bitcoin::bip32::KeySource; use crate::fixed_script_wallet::{ - Chain, OutputScriptType, ReplayProtection, RootWalletKeys, WalletScripts, + OutputScriptType, ReplayProtection, RootWalletKeys, ScriptId, WalletOutputScript, }; use crate::Network; pub type Bip32DerivationMap = std::collections::BTreeMap; /// Check if a fingerprint matches any xpub in the wallet -fn has_fingerprint( - wallet_keys: &RootWalletKeys, - fingerprint: miniscript::bitcoin::bip32::Fingerprint, -) -> bool { - wallet_keys - .xpubs - .iter() - .any(|xpub| xpub.fingerprint() == fingerprint) -} - -/// Find an xpub in the wallet by fingerprint -fn find_xpub_by_fingerprint( - wallet_keys: &RootWalletKeys, - fingerprint: miniscript::bitcoin::bip32::Fingerprint, -) -> Option<&miniscript::bitcoin::bip32::Xpub> { - wallet_keys - .xpubs - .iter() - .find(|xpub| xpub.fingerprint() == fingerprint) -} - -/// Make sure that deriving from the wallet xpubs matches keys in the derivation map -/// Check if BIP32 derivation info belongs to the wallet keys (non-failing) -/// Returns true if all fingerprints match, false if any don't match (external wallet) -pub fn is_bip32_derivation_for_wallet( - wallet_keys: &RootWalletKeys, - derivation_map: &Bip32DerivationMap, -) -> bool { - derivation_map - .iter() - .all(|(_, (fingerprint, _))| has_fingerprint(wallet_keys, *fingerprint)) -} - /// Helper function to derive a public key from an xpub and derivation path fn derive_pubkey( secp: &secp256k1::Secp256k1, @@ -414,120 +381,8 @@ pub fn verify_ecdsa_signature_zcash( } } -fn assert_bip32_derivation_map( - wallet_keys: &RootWalletKeys, - derivation_map: &Bip32DerivationMap, -) -> Result<(), String> { - for (key, (fingerprint, path)) in derivation_map { - let xpub = find_xpub_by_fingerprint(wallet_keys, *fingerprint) - .ok_or_else(|| format!("No xpub found with fingerprint {}", fingerprint))?; - let derived_key = xpub - .derive_pub(&secp256k1::Secp256k1::new(), path) - .map_err(|e| format!("Failed to derive pubkey: {}", e))?; - if derived_key.public_key != *key { - return Err(format!( - "Derived pubkey {} does not match derivation map {}", - derived_key.public_key, key - )); - } - } - Ok(()) -} - pub type TapKeyOrigins = std::collections::BTreeMap, KeySource)>; -/// Check if tap key origins belong to the wallet keys (non-failing) -/// Returns true if all fingerprints match, false if any don't match (external wallet) -pub fn is_tap_key_origins_for_wallet( - wallet_keys: &RootWalletKeys, - tap_key_origins: &TapKeyOrigins, -) -> bool { - tap_key_origins - .iter() - .all(|(_, (_, (fingerprint, _)))| has_fingerprint(wallet_keys, *fingerprint)) -} - -/// Derives a public key from an xpub using the derivation path found in the input's tap_key_origins -/// -/// This searches for a derivation path matching the xpub's fingerprint and derives the public key. -/// -/// # Returns -/// - `Ok(PublicKey)` if a matching derivation path is found and derivation succeeds -/// - `Err(String)` if no matching derivation path is found or derivation fails -pub fn derive_pubkey_from_tap_key_origins( - secp: &secp256k1::Secp256k1, - xpub: &miniscript::bitcoin::bip32::Xpub, - tap_key_origins: &TapKeyOrigins, -) -> Result { - let xpub_fingerprint = xpub.fingerprint(); - let derivation_path = - find_tap_key_origins_path(tap_key_origins, xpub_fingerprint).ok_or_else(|| { - format!( - "No tap key origin found for xpub fingerprint {}", - xpub_fingerprint - ) - })?; - - derive_pubkey(secp, xpub, derivation_path) -} - -fn assert_tap_key_origins( - wallet_keys: &RootWalletKeys, - tap_key_origins: &TapKeyOrigins, -) -> Result<(), String> { - for (key, (_, (fingerprint, path))) in tap_key_origins { - let xpub = find_xpub_by_fingerprint(wallet_keys, *fingerprint) - .ok_or_else(|| format!("No xpub found with fingerprint {}", fingerprint))?; - let derived_key = xpub - .derive_pub(&secp256k1::Secp256k1::new(), path) - .map_err(|e| format!("Failed to derive pubkey: {}", e))? - .to_x_only_pub(); - if derived_key != *key { - return Err(format!( - "Derived pubkey {} does not match derivation map {}", - derived_key, key - )); - } - } - Ok(()) -} - -struct WalletDerivationPath { - #[allow(dead_code)] - prefix: DerivationPath, - chain: u32, - index: u32, -} - -fn parse_derivation_path(path: &DerivationPath) -> Result { - let length = path.len(); - if length < 2 { - return Err("Invalid path".to_string()); - } - let prefix = path[..length - 2].to_vec(); - let chain = path[length - 2]; - let index = path[length - 1]; - - let chain = if let ChildNumber::Normal { index } = chain { - index - } else { - return Err("Invalid chain number".to_string()); - }; - - let index = if let ChildNumber::Normal { index } = index { - index - } else { - return Err("Invalid index".to_string()); - }; - - Ok(WalletDerivationPath { - prefix: DerivationPath::from_iter(prefix), - chain, - index, - }) -} - -/// Extract derivation paths from either BIP32 derivation or tap key origins pub fn get_derivation_paths(input: &Input) -> Vec<&DerivationPath> { if !input.bip32_derivation.is_empty() { input @@ -544,99 +399,26 @@ pub fn get_derivation_paths(input: &Input) -> Vec<&DerivationPath> { } } -/// Extract derivation paths from PSBT output metadata -pub fn get_output_derivation_paths( - output: &miniscript::bitcoin::psbt::Output, -) -> Vec<&DerivationPath> { - if !output.bip32_derivation.is_empty() { - output - .bip32_derivation - .values() - .map(|(_, path)| path) - .collect() - } else { - output - .tap_key_origins - .values() - .map(|(_, (_, path))| path) - .collect() - } -} +pub fn parse_shared_chain_and_index(input: &Input) -> Result<(u32, u32), String> { + use crate::fixed_script_wallet::wallet_scripts::path_chain_index; -pub fn parse_shared_derivation_path(key_origins: &[&DerivationPath]) -> Result<(u32, u32), String> { - let paths = key_origins - .iter() - .map(|path| parse_derivation_path(path)) - .collect::, String>>()?; + let paths = get_derivation_paths(input); if paths.is_empty() { - return Err("Invalid input".to_string()); + return Err("no derivation paths".to_string()); } - // if chain and index are the same for all paths, return the chain and index - let chain = paths[0].chain; - let index = paths[0].index; - for path in paths { - if path.chain != chain || path.index != index { - return Err("Invalid input".to_string()); + let (chain, index) = + path_chain_index(paths[0]).ok_or_else(|| "invalid derivation path".to_string())?; + for path in &paths[1..] { + if path_chain_index(path) != Some((chain, index)) { + return Err("inconsistent derivation paths".to_string()); } } Ok((chain, index)) } -pub fn parse_shared_chain_and_index(input: &Input) -> Result<(u32, u32), String> { - if input.bip32_derivation.is_empty() && input.tap_key_origins.is_empty() { - return Err( - "Invalid input: both bip32_derivation and tap_key_origins are empty".to_string(), - ); - } - - let derivation_paths = get_derivation_paths(input); - parse_shared_derivation_path(&derivation_paths) -} - -fn assert_wallet_output_script( - wallet_keys: &RootWalletKeys, - chain: Chain, - index: u32, - script_pub_key: &ScriptBuf, -) -> Result<(), String> { - let derived_scripts = WalletScripts::from_wallet_keys( - wallet_keys, - chain, - index, - &Network::Bitcoin.output_script_support(), - ) - .map_err(|e| e.to_string())?; - if derived_scripts.output_script() != *script_pub_key { - return Err(format!( - "Script mismatch: from script {:?} != from path {:?}", - derived_scripts.output_script(), - script_pub_key - )); - } - Ok(()) -} - -/// asserts that the script belongs to the wallet -pub fn assert_wallet_input( - wallet_keys: &RootWalletKeys, - input: &Input, - output_script: &ScriptBuf, -) -> Result<(), String> { - if input.bip32_derivation.is_empty() { - assert_tap_key_origins(wallet_keys, &input.tap_key_origins)?; - } else { - assert_bip32_derivation_map(wallet_keys, &input.bip32_derivation)?; - } - let (chain, index) = parse_shared_chain_and_index(input)?; - let chain = Chain::try_from(chain).map_err(|e| e.to_string())?; - assert_wallet_output_script(wallet_keys, chain, index, output_script)?; - Ok(()) -} - #[derive(Debug)] pub enum OutputScriptError { OutputIndexOutOfBounds { vout: u32 }, - BothUtxoFieldsSet, NoUtxoFields, } @@ -646,9 +428,6 @@ impl std::fmt::Display for OutputScriptError { OutputScriptError::OutputIndexOutOfBounds { vout } => { write!(f, "Output index {} out of bounds", vout) } - OutputScriptError::BothUtxoFieldsSet => { - write!(f, "Both witness_utxo and non_witness_utxo are set") - } OutputScriptError::NoUtxoFields => { write!(f, "Neither witness_utxo nor non_witness_utxo is set") } @@ -658,21 +437,6 @@ impl std::fmt::Display for OutputScriptError { impl std::error::Error for OutputScriptError {} -/// Identifies a script by its chain and index in the wallet -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ScriptId { - pub chain: u32, - pub index: u32, -} - -/// ScriptId with value — used by `from_half_signed_legacy_transaction` -#[derive(Debug, Clone, Copy)] -pub struct ScriptIdWithValue { - pub chain: u32, - pub index: u32, - pub value: u64, -} - /// Identifies a key in the wallet triple (user, backup, bitgo) #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SignerKey { @@ -755,65 +519,37 @@ 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.script_type { - OutputScriptType::P2sh => Ok(InputScriptType::P2sh), - OutputScriptType::P2shP2wsh => Ok(InputScriptType::P2shP2wsh), - OutputScriptType::P2wsh => Ok(InputScriptType::P2wsh), - OutputScriptType::P2trLegacy => Ok(InputScriptType::P2trLegacy), + /// Convert a verified `OutputScriptType` to `InputScriptType`, refining P2trMusig2 variants + /// using psbt_input metadata (tap_script_sigs presence → ScriptPath vs KeyPath). + /// Map an `OutputScriptType` to an `InputScriptType`, using PSBT input metadata to + /// distinguish P2trMusig2 key-path from script-path. + pub fn from_output_script_type(script_type: OutputScriptType, psbt_input: &Input) -> Self { + match script_type { + OutputScriptType::P2sh => Self::P2sh, + OutputScriptType::P2shP2wsh => Self::P2shP2wsh, + OutputScriptType::P2wsh => Self::P2wsh, + OutputScriptType::P2trLegacy => Self::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) + Self::P2trMusig2ScriptPath } else { - Ok(InputScriptType::P2trMusig2KeyPath) - } - } - OutputScriptType::P2mr => Ok(InputScriptType::P2mr), - } - } - - /// Detects the script type from a script_id chain and PSBT input metadata - /// - /// # Arguments - /// - `script_id`: Optional script ID containing chain information (None for replay protection inputs) - /// - `psbt_input`: The PSBT input containing signature metadata - /// - `output_script`: The output script being spent - /// - `replay_protection`: Replay protection configuration - /// - /// # Returns - /// - `Ok(InputScriptType)` with the detected script type - /// - `Err(String)` if the script type cannot be determined - pub fn detect( - script_id: Option, - psbt_input: &Input, - output_script: &ScriptBuf, - replay_protection: &ReplayProtection, - ) -> Result { - // For replay protection inputs (no script_id), detect from output script - match script_id { - Some(id) => Self::from_script_id(id, psbt_input), - None => { - if replay_protection.is_replay_protection_input(output_script) { - Ok(InputScriptType::P2shP2pk) - } else { - Err("Input without script_id is not a replay protection input".to_string()) + Self::P2trMusig2KeyPath } } + OutputScriptType::P2mr => Self::P2mr, } } pub fn as_str(self) -> &'static str { match self { - Self::P2shP2pk => "p2shP2pk", - Self::P2sh => "p2sh", - Self::P2shP2wsh => "p2shP2wsh", - Self::P2wsh => "p2wsh", - Self::P2trLegacy => "p2trLegacy", - Self::P2trMusig2KeyPath => "p2trMusig2", + Self::P2shP2pk => "p2shP2pk", + Self::P2sh => "p2sh", + Self::P2shP2wsh => "p2shP2wsh", + Self::P2wsh => "p2wsh", + Self::P2trLegacy => "p2trLegacy", + Self::P2trMusig2KeyPath => "p2trMusig2", Self::P2trMusig2ScriptPath => "p2trMusig2Script", - Self::P2mr => "p2mr", + Self::P2mr => "p2mr", } } } @@ -828,21 +564,13 @@ pub struct ParsedInput { pub script_id: Option, pub script_type: InputScriptType, pub sequence: u32, + /// Full BIP32 derivation path from the wallet xpub (e.g. `[chain, index]`). + /// `None` for replay-protection inputs which have no wallet derivation. + pub derivation_path: Option, } impl ParsedInput { - /// Parse a PSBT input with wallet keys to identify if it belongs to the wallet - /// - /// # Arguments - /// - `psbt_input`: The PSBT input metadata - /// - `tx_input`: The transaction input - /// - `wallet_keys`: The wallet's root keys for deriving scripts - /// - `replay_protection`: Scripts that are allowed as inputs without wallet validation - /// - `network`: The network for address generation - /// - /// # Returns - /// - `Ok(ParsedInput)` with address, value, and optional script_id - /// - `Err(ParseInputError)` if validation fails + /// Parse a PSBT input with wallet keys to identify if it belongs to the wallet. pub fn parse( psbt_input: &Input, tx_input: &miniscript::bitcoin::TxIn, @@ -850,40 +578,41 @@ impl ParsedInput { replay_protection: &ReplayProtection, network: Network, ) -> Result { - // Get output script and value from the UTXO let (output_script, value) = get_output_script_and_value(psbt_input, tx_input.previous_output) .map_err(ParseInputError::Utxo)?; - // Check if this is a replay protection input let is_replay_protection = replay_protection.is_replay_protection_input(output_script); - let script_id = if is_replay_protection { - None + let (script_id, derivation_path, script_type) = if is_replay_protection { + (None, None, InputScriptType::P2shP2pk) } else { - // Parse derivation info and validate - let (chain, index) = - parse_shared_chain_and_index(psbt_input).map_err(ParseInputError::Derivation)?; - - // Validate that the input belongs to the wallet - assert_wallet_input(wallet_keys, psbt_input, output_script) - .map_err(ParseInputError::WalletValidation)?; + let wos = WalletOutputScript::from_psbt( + wallet_keys, + &psbt_input.bip32_derivation, + &psbt_input.tap_key_origins, + psbt_input.witness_script.is_some(), + output_script, + network, + ) + .map_err(ParseInputError::WalletValidation)? + .ok_or_else(|| { + ParseInputError::WalletValidation( + "no derivation path matched wallet keys".to_string(), + ) + })?; - Some(ScriptId { chain, index }) + let script_id = wos.script_id(); + let input_type = InputScriptType::from_output_script_type(wos.script_type, psbt_input); + (script_id, Some(wos.derivation_path), input_type) }; - // Convert script to address let address = crate::address::networks::from_output_script_with_network( output_script.as_script(), network, ) .map_err(ParseInputError::Address)?; - // Detect the script type using script_id chain information - let script_type = - InputScriptType::detect(script_id, psbt_input, output_script, replay_protection) - .map_err(ParseInputError::ScriptTypeDetection)?; - Ok(Self { previous_output: tx_input.previous_output, address, @@ -892,6 +621,7 @@ impl ParsedInput { script_id, script_type, sequence: tx_input.sequence.0, + derivation_path, }) } } @@ -946,7 +676,8 @@ pub fn get_output_script_and_value( prevout: OutPoint, ) -> Result<(&ScriptBuf, miniscript::bitcoin::Amount), OutputScriptError> { match (&input.witness_utxo, &input.non_witness_utxo) { - (Some(witness_utxo), None) => Ok((&witness_utxo.script_pubkey, witness_utxo.value)), + // Prefer witness_utxo when both are set (common in some wallet implementations) + (Some(witness_utxo), _) => Ok((&witness_utxo.script_pubkey, witness_utxo.value)), (None, Some(non_witness_utxo)) => { let output = non_witness_utxo .output @@ -954,7 +685,6 @@ pub fn get_output_script_and_value( .ok_or(OutputScriptError::OutputIndexOutOfBounds { vout: prevout.vout })?; Ok((&output.script_pubkey, output.value)) } - (Some(_), Some(_)) => Err(OutputScriptError::BothUtxoFieldsSet), (None, None) => Err(OutputScriptError::NoUtxoFields), } } @@ -1051,6 +781,7 @@ pub fn validate_psbt_wallet_inputs( psbt: &Psbt, wallet_keys: &RootWalletKeys, replay_protection: &ReplayProtection, + network: Network, ) -> Result<(), PsbtValidationError> { let prevouts = psbt .unsigned_tx @@ -1085,7 +816,26 @@ pub fn validate_psbt_wallet_inputs( continue; } - if let Err(e) = assert_wallet_input(wallet_keys, input, output_script) { + let validation_result = WalletOutputScript::from_psbt( + wallet_keys, + &input.bip32_derivation, + &input.tap_key_origins, + input.witness_script.is_some(), + output_script, + network, + ) + .and_then(|opt| opt.ok_or_else(|| "no derivation path matched wallet keys".to_string())) + .and_then(|wos| { + if wos.chain_standard() { + Ok(()) + } else { + Err(format!( + "nonstandard derivation path {} for script type {}", + wos.derivation_path, wos.script_type + )) + } + }); + if let Err(e) = validation_result { validation_errors.push(InputValidationError { input_index, prevout: *prevout, @@ -1311,18 +1061,19 @@ pub mod test_helpers { .expect("Failed to get wallet keys"); let wallet_keys = wallet_xprv.to_root_wallet_keys(); - validate_psbt_wallet_inputs(&psbt, &wallet_keys, &replay_protection).unwrap(); + validate_psbt_wallet_inputs(&psbt, &wallet_keys, &replay_protection, network).unwrap(); // should fail with invalid wallet keys - this reverses the keys so ALL inputs should fail let reversed_wallet_keys = get_reversed_wallet_keys(&wallet_keys); - + let actual_psbt_error = validate_psbt_wallet_inputs( &psbt, &reversed_wallet_keys, &replay_protection, + network, ) .unwrap_err(); - + // Create expected errors - one for each non-replay-protected input let expected_errors = expected_validation_errors_non_wallet_inputs(&psbt, &replay_protection); let expected_psbt_error = PsbtValidationError::InvalidInputs(expected_errors); @@ -1330,11 +1081,12 @@ pub mod test_helpers { // should fail with a single error for the replay protection input when empty ReplayProtection is passed let empty_replay_protection = ReplayProtection::new(vec![]); - + let actual_psbt_error = validate_psbt_wallet_inputs( &psbt, &wallet_keys, &empty_replay_protection, + network, ) .unwrap_err(); diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_output.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_output.rs index bdf8c0f261c..fa413f9ccdf 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_output.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_output.rs @@ -1,12 +1,9 @@ +use miniscript::bitcoin::bip32::DerivationPath; use miniscript::bitcoin::psbt::Output; -use miniscript::bitcoin::ScriptBuf; -use crate::fixed_script_wallet::{Chain, RootWalletKeys, WalletScripts}; +use crate::fixed_script_wallet::{RootWalletKeys, ScriptId, WalletOutputScript}; use crate::Network; -// Re-export ScriptId from psbt_wallet_input -pub use super::psbt_wallet_input::ScriptId; - /// Parsed output from a PSBT transaction #[derive(Debug, Clone)] pub struct ParsedOutput { @@ -15,21 +12,12 @@ pub struct ParsedOutput { pub value: u64, pub script_id: Option, pub paygo: bool, + /// Full BIP32 derivation path from the wallet xpub (e.g. `[chain, index]`). + /// `None` for outputs that do not belong to this wallet. + pub derivation_path: Option, } impl ParsedOutput { - /// Parse a PSBT output with wallet keys to identify if it belongs to the wallet - /// - /// # Arguments - /// - `psbt_output`: The PSBT output metadata - /// - `tx_output`: The transaction output - /// - `wallet_keys`: The wallet's root keys for deriving scripts - /// - `network`: The network for address generation - /// - `paygo_pubkeys`: Optional list of public keys for PayGo attestation verification - /// - /// # Returns - /// - `Ok(ParsedOutput)` with optional address, script bytes, value, and optional script_id - /// - `Err(ParseOutputError)` if validation fails pub fn parse( psbt_output: &Output, tx_output: &miniscript::bitcoin::TxOut, @@ -39,16 +27,24 @@ impl ParsedOutput { ) -> Result { let script = &tx_output.script_pubkey; - // Try to match output to wallet - let script_id = match_output_to_wallet(wallet_keys, psbt_output, script, network) - .map_err(ParseOutputError::WalletMatch)?; + let (script_id, derivation_path) = match WalletOutputScript::from_psbt( + wallet_keys, + &psbt_output.bip32_derivation, + &psbt_output.tap_key_origins, + false, + script, + network, + ) + .map_err(ParseOutputError::WalletMatch)? + { + Some(wos) => (wos.script_id(), Some(wos.derivation_path)), + None => (None, None), + }; - // Try to convert script to address (may fail for non-standard scripts) let address = crate::address::networks::from_output_script_with_network(script.as_script(), network) .ok(); - // Check if this output has a PayGo attestation and validate it let paygo = crate::paygo::has_paygo_attestation_verify( psbt_output, address.as_deref(), @@ -62,12 +58,13 @@ impl ParsedOutput { value: tx_output.value.to_sat(), script_id, paygo, + derivation_path, }) } /// Returns true if this is an external output (not belonging to the wallet) pub fn is_external(&self) -> bool { - self.script_id.is_none() + self.derivation_path.is_none() } } @@ -92,71 +89,3 @@ impl std::fmt::Display for ParseOutputError { } impl std::error::Error for ParseOutputError {} - -/// Try to match an output script to wallet keys using PSBT output metadata -/// Returns Some(ScriptId) if the script belongs to the wallet, None otherwise -/// -/// Logic: -/// - If no derivation info → external output (None) -/// - If derivation info fingerprints don't match wallet → external output (None) -/// - If derivation info matches wallet but script doesn't → error (corruption) -fn match_output_to_wallet( - wallet_keys: &RootWalletKeys, - psbt_output: &Output, - script: &ScriptBuf, - network: Network, -) -> Result, String> { - use super::psbt_wallet_input; - - // Check if output has BIP32 derivation or tap key origins - if psbt_output.bip32_derivation.is_empty() && psbt_output.tap_key_origins.is_empty() { - // No derivation info, treat as external output - return Ok(None); - } - - // Check if the derivation info belongs to our wallet keys - let belongs_to_wallet = if !psbt_output.bip32_derivation.is_empty() { - psbt_wallet_input::is_bip32_derivation_for_wallet( - wallet_keys, - &psbt_output.bip32_derivation, - ) - } else { - psbt_wallet_input::is_tap_key_origins_for_wallet(wallet_keys, &psbt_output.tap_key_origins) - }; - - if !belongs_to_wallet { - // Derivation info references different wallet keys, treat as external output - return Ok(None); - } - - // Derivation info belongs to our wallet, parse and validate - let derivation_paths = psbt_wallet_input::get_output_derivation_paths(psbt_output); - - // Parse the shared chain and index from derivation paths - let (chain, index) = psbt_wallet_input::parse_shared_derivation_path(&derivation_paths) - .map_err(|e| format!("Failed to parse output derivation path: {}", e))?; - - // Derive the expected script for this wallet - let chain_enum = - Chain::try_from(chain).map_err(|e| format!("Invalid chain value {}: {}", chain, e))?; - - let derived_scripts = WalletScripts::from_wallet_keys( - wallet_keys, - chain_enum, - index, - &network.output_script_support(), - ) - .map_err(|e| format!("Failed to derive wallet scripts: {}", e))?; - - if derived_scripts.output_script().as_script() == script.as_script() { - Ok(Some(ScriptId { chain, index })) - } else { - // Script doesn't match even though keys are ours - this is an error - Err(format!( - "Output script mismatch: expected wallet output at chain={}, index={} but script doesn't match. Expected: {}, Got: {}", - chain, index, - derived_scripts.output_script(), - script - )) - } -} diff --git a/packages/wasm-utxo/src/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/mod.rs index 55abc13622b..1f7ed2d7420 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/mod.rs @@ -2,6 +2,7 @@ /// These are not based on descriptors. pub mod bitgo_psbt; pub mod replay_protection; +pub mod script_id; mod wallet_keys; pub mod wallet_scripts; @@ -9,5 +10,6 @@ pub mod wallet_scripts; pub mod test_utils; pub use replay_protection::*; +pub use script_id::{Chain, Scope, ScriptId, ScriptIdWithValue}; pub use wallet_keys::*; pub use wallet_scripts::*; diff --git a/packages/wasm-utxo/src/fixed_script_wallet/script_id.rs b/packages/wasm-utxo/src/fixed_script_wallet/script_id.rs new file mode 100644 index 00000000000..95ff3a88098 --- /dev/null +++ b/packages/wasm-utxo/src/fixed_script_wallet/script_id.rs @@ -0,0 +1,111 @@ +use std::convert::TryFrom; +use std::str::FromStr; + +use super::wallet_scripts::{path_chain_index, OutputScriptType, WalletOutputScript}; + +/// Whether a chain is for receiving (external) or change (internal) addresses. +#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] +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, +} + +/// 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 { + /// 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, + OutputScriptType::P2mr => 360, + }) + match self.scope { + Scope::External => 0, + Scope::Internal => 1, + } + } +} + +impl TryFrom for Chain { + type Error = String; + + fn try_from(value: u32) -> Result { + 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), + 360 => (OutputScriptType::P2mr, Scope::External), + 361 => (OutputScriptType::P2mr, Scope::Internal), + _ => return Err(format!("no chain for {}", value)), + }; + Ok(Chain::new(script_type, scope)) + } +} + +impl FromStr for Chain { + type Err = String; + + fn from_str(s: &str) -> Result { + let chain: u32 = u32::from_str(s).map_err(|v| v.to_string())?; + Chain::try_from(chain) + } +} + +/// Identifies a wallet script by its chain and index in the derivation path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ScriptId { + pub chain: u32, + pub index: u32, +} + +/// ScriptId with value — used by `from_half_signed_legacy_transaction` +#[derive(Debug, Clone, Copy)] +pub struct ScriptIdWithValue { + pub chain: u32, + pub index: u32, + pub value: u64, +} + +impl WalletOutputScript { + /// Returns true if the chain component of the derivation path encodes the script type + /// per BitGo convention (chain 0/1 = P2sh, 20/21 = P2wsh, etc.). + pub fn chain_standard(&self) -> bool { + path_chain_index(&self.derivation_path) + .and_then(|(chain, _)| Chain::try_from(chain).ok()) + .is_some_and(|c| c.script_type == self.script_type) + } + + /// Returns `Some(ScriptId)` if the derivation path is chain-standard, `None` otherwise. + pub fn script_id(&self) -> Option { + path_chain_index(&self.derivation_path) + .filter(|_| self.chain_standard()) + .map(|(chain, index)| ScriptId { chain, index }) + } +} 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 9323d3a20df..f0bc210298a 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 @@ -3,8 +3,9 @@ pub mod fixtures; pub mod psbt_compare; +use super::script_id::{Chain, Scope}; use super::wallet_keys::XpubTriple; -use super::wallet_scripts::{Chain, OutputScriptType, Scope, WalletScripts}; +use super::wallet_scripts::{chain_index_path, OutputScriptType, 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,8 +39,11 @@ 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::new(OutputScriptType::P2wsh, Scope::External), - 0, + OutputScriptType::P2wsh, + &chain_index_path( + Chain::new(OutputScriptType::P2wsh, Scope::External).value(), + 0, + ), &Network::Bitcoin.output_script_support(), ) .unwrap(); diff --git a/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs b/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs index cc6e0f0933d..706133bf718 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/wallet_keys.rs @@ -91,46 +91,42 @@ impl RootWalletKeys { ) } - pub fn derive_for_chain_and_index( - &self, - chain: u32, - index: u32, - ) -> Result { - let cache_key = (chain, index); - - // Check cache first - { - let cache = self.derivation_cache.borrow(); - if let Some(cached) = cache.get(&cache_key) { + /// Derive keys from `self.prefix_derived` along `path`. + /// Caches using the last two Normal path components as `(chain, index)`. + pub fn derive_path(&self, path: &DerivationPath) -> Result { + let cache_key: Option<(u32, u32)> = { + let children: Vec<_> = path.into_iter().cloned().collect(); + let n = children.len(); + match (children.get(n.wrapping_sub(2)), children.last()) { + ( + Some(ChildNumber::Normal { index: c }), + Some(ChildNumber::Normal { index: i }), + ) => Some((*c, *i)), + _ => None, + } + }; + if let Some(key) = cache_key { + if let Some(cached) = self.derivation_cache.borrow().get(&key) { return Ok(*cached); } } - - // Derive from prefix to chain+index (2 levels) - let path = DerivationPath::from(vec![ - ChildNumber::Normal { index: chain }, - ChildNumber::Normal { index }, - ]); let derived: XpubTriple = self .prefix_derived .iter() .map(|xpub| { - xpub.derive_pub(&self.secp, &path) + xpub.derive_pub(&self.secp, path) .map_err(|e| WasmUtxoError::new(&format!("Error deriving xpub: {}", e))) }) .collect::, _>>()? .try_into() .map_err(|_| WasmUtxoError::new("Expected exactly 3 derived xpubs"))?; - - // Cache the result (with bounded size) - { + if let Some(key) = cache_key { let mut cache = self.derivation_cache.borrow_mut(); if cache.len() >= DERIVATION_CACHE_MAX_SIZE { cache.clear(); } - cache.insert(cache_key, derived); + cache.insert(key, derived); } - Ok(derived) } } @@ -195,6 +191,8 @@ pub mod tests { #[test] fn it_works() { let keys = get_test_wallet_keys("test"); - assert!(keys.derive_for_chain_and_index(0, 0).is_ok()); + assert!(keys + .derive_path(&crate::fixed_script_wallet::wallet_scripts::chain_index_path(0, 0)) + .is_ok()); } } 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 0adaa69668c..c61d542edd7 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,12 +100,13 @@ 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_index_path; #[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(0, 0).unwrap(); + let derived_keys = wallet_keys.derive_path(&chain_index_path(0, 0)).unwrap(); let pub_triple = to_pub_triple(&derived_keys); // Build a valid 2-of-3 multisig script @@ -123,7 +124,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(0, 42).unwrap(); + let derived_keys = wallet_keys.derive_path(&chain_index_path(0, 42)).unwrap(); let original_keys = to_pub_triple(&derived_keys); // Build script from keys @@ -163,7 +164,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(0, 0).unwrap(); + let derived_keys = wallet_keys.derive_path(&chain_index_path(0, 0)).unwrap(); let pub_triple = to_pub_triple(&derived_keys); // Build script with wrong quorum (OP_1 instead of OP_2) @@ -187,7 +188,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(0, 0).unwrap(); + let derived_keys = wallet_keys.derive_path(&chain_index_path(0, 0)).unwrap(); let pub_triple = to_pub_triple(&derived_keys); // Build script with wrong total (OP_4 instead of OP_3) @@ -211,7 +212,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(0, 0).unwrap(); + let derived_keys = wallet_keys.derive_path(&chain_index_path(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/checksigverify.rs b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/checksigverify.rs index af92ae3b0ff..c577a9fc8b0 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/checksigverify.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/checksigverify.rs @@ -446,7 +446,8 @@ mod tests { #[test] fn test_p2mr_chain_values() { - use crate::fixed_script_wallet::wallet_scripts::{Chain, OutputScriptType, Scope}; + use crate::fixed_script_wallet::script_id::{Chain, Scope}; + use crate::fixed_script_wallet::wallet_scripts::OutputScriptType; use std::convert::TryFrom; // Chain 360: external P2MR 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 e067adefd8d..d977b7da5de 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 @@ -16,13 +16,13 @@ pub use checksigverify::{ pub use singlesig::{build_p2pk_script, parse_p2pk_script, ScriptP2shP2pk}; use crate::address::networks::OutputScriptSupport; -use crate::bitcoin::bip32::{ChildNumber, DerivationPath}; -use crate::bitcoin::ScriptBuf; +use crate::bitcoin::bip32::{ChildNumber, DerivationPath, Fingerprint}; +use crate::bitcoin::secp256k1::PublicKey as Secp256k1PublicKey; +use crate::bitcoin::{ScriptBuf, TapLeafHash, XOnlyPublicKey}; use crate::error::WasmUtxoError; -use crate::fixed_script_wallet::wallet_keys::{ - to_pub_triple, PubTriple, RootWalletKeys, XpubTriple, -}; -use std::convert::TryFrom; +use crate::fixed_script_wallet::wallet_keys::{to_pub_triple, PubTriple, RootWalletKeys}; +use crate::Network; +use std::collections::BTreeMap; use std::str::FromStr; /// Scripts that belong to fixed-script BitGo wallets. @@ -42,30 +42,13 @@ pub enum WalletScripts { P2mr(ScriptP2mr), } -impl std::fmt::Display for WalletScripts { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}", - match self { - WalletScripts::P2sh(_) => "P2sh".to_string(), - WalletScripts::P2shP2wsh(_) => "P2shP2wsh".to_string(), - WalletScripts::P2wsh(_) => "P2wsh".to_string(), - WalletScripts::P2trLegacy(_) => "P2trLegacy".to_string(), - WalletScripts::P2trMusig2(_) => "P2trMusig2".to_string(), - WalletScripts::P2mr(_) => "P2mr".to_string(), - } - ) - } -} - impl WalletScripts { pub fn new( keys: &PubTriple, - chain: Chain, + script_type: OutputScriptType, script_support: &OutputScriptSupport, ) -> Result { - match chain.script_type { + match script_type { OutputScriptType::P2sh => { script_support.assert_legacy()?; let script = build_multisig_script_2_of_3(keys); @@ -105,14 +88,12 @@ impl WalletScripts { pub fn from_wallet_keys( wallet_keys: &RootWalletKeys, - chain: Chain, - index: u32, + script_type: OutputScriptType, + path: &DerivationPath, script_support: &OutputScriptSupport, ) -> Result { - let derived_keys = wallet_keys - .derive_for_chain_and_index(chain.value(), index) - .unwrap(); - WalletScripts::new(&to_pub_triple(&derived_keys), chain, script_support) + let derived_keys = wallet_keys.derive_path(path)?; + WalletScripts::new(&to_pub_triple(&derived_keys), script_type, script_support) } pub fn output_script(&self) -> ScriptBuf { @@ -127,81 +108,6 @@ impl WalletScripts { } } -/// Whether a chain is for receiving (external) or change (internal) addresses. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] -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, -} - -/// 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 { - /// 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, - OutputScriptType::P2mr => 360, - }) + match self.scope { - Scope::External => 0, - Scope::Internal => 1, - } - } -} - -impl TryFrom for Chain { - type Error = String; - - fn try_from(value: u32) -> Result { - 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), - 360 => (OutputScriptType::P2mr, Scope::External), - 361 => (OutputScriptType::P2mr, Scope::Internal), - _ => return Err(format!("no chain for {}", value)), - }; - Ok(Chain::new(script_type, scope)) - } -} - -impl FromStr for Chain { - type Err = String; - - fn from_str(s: &str) -> Result { - let chain: u32 = u32::from_str(s).map_err(|v| v.to_string())?; - Chain::try_from(chain) - } -} - /// Fixed-script wallet script types (2-of-3 multisig) /// /// This enum represents the abstract script type, independent of chain (external/internal). @@ -286,38 +192,187 @@ impl std::fmt::Display for OutputScriptType { } } -/// Return derived WalletKeys. All keys are derived with the same path. -#[allow(dead_code)] -pub fn derive_xpubs_with_path( - xpubs: &XpubTriple, - ctx: &crate::bitcoin::secp256k1::Secp256k1, - p: DerivationPath, -) -> XpubTriple { - let derived = xpubs - .iter() - .map(|k| k.derive_pub(ctx, &p).unwrap()) - .collect::>(); - derived.try_into().expect("could not convert vec to array") +impl OutputScriptType { + fn is_network_supported(self, script_support: &OutputScriptSupport) -> bool { + match self { + Self::P2sh => true, + Self::P2shP2wsh | Self::P2wsh => script_support.segwit, + Self::P2trLegacy | Self::P2trMusig2 => script_support.taproot, + Self::P2mr => script_support.p2mr, + } + } + + fn is_script_compatible(self, script: &ScriptBuf, has_witness_script: bool) -> bool { + match self { + Self::P2wsh => script.is_p2wsh(), + // Skip plain P2sh only when we know for certain it's P2shP2wsh (witness_script present). + // When has_witness_script=false (unknown), try both P2sh and P2shP2wsh. + Self::P2sh => script.is_p2sh() && !has_witness_script, + Self::P2shP2wsh => script.is_p2sh(), + Self::P2trLegacy | Self::P2trMusig2 => script.is_p2tr(), + Self::P2mr => true, + } + } + + /// Try to find which script type the wallet uses for `output_script` at `path`. + /// Iterates all known script types; skips types unsupported by the network or + /// incompatible with the script shape, then checks by derivation. + /// Returns the first matching type, or `None` if none match. + pub fn check( + wallet_keys: &RootWalletKeys, + output_script: &ScriptBuf, + has_witness_script: bool, + path: &DerivationPath, + script_support: &OutputScriptSupport, + ) -> Option { + let (chain, index) = path_chain_index(path)?; + let derived_keys = wallet_keys + .derive_path(&chain_index_path(chain, index)) + .ok()?; + let pub_triple = to_pub_triple(&derived_keys); + for &script_type in Self::all() { + if !script_type.is_network_supported(script_support) { + continue; + } + if !script_type.is_script_compatible(output_script, has_witness_script) { + continue; + } + if WalletScripts::new(&pub_triple, script_type, script_support) + .ok() + .is_some_and(|s| s.output_script() == *output_script) + { + return Some(script_type); + } + } + None + } +} + +/// Extract the (chain, index) tail from a derivation path (last two Normal components). +pub(crate) fn path_chain_index(path: &DerivationPath) -> Option<(u32, u32)> { + let children: Vec = path.into_iter().cloned().collect(); + let n = children.len(); + if n < 2 { + return None; + } + match (children[n - 2], children[n - 1]) { + (ChildNumber::Normal { index: chain }, ChildNumber::Normal { index }) => { + Some((chain, index)) + } + _ => None, + } +} + +/// A wallet output script matched to a derivation path. +#[derive(Debug, Clone)] +pub struct WalletOutputScript { + pub script_type: OutputScriptType, + /// The BIP32 derivation path; last two Normal components are (chain, index). + pub derivation_path: DerivationPath, } -pub fn derive_xpubs( - xpubs: &XpubTriple, - ctx: &crate::bitcoin::secp256k1::Secp256k1, - chain: Chain, - index: u32, -) -> XpubTriple { - let p = DerivationPath::from_str("m/0/0") - .unwrap() - .child(ChildNumber::Normal { - index: chain.value(), +impl WalletOutputScript { + /// Try to match `output_script` at `path` against all script types supported by `network`. + /// Returns `None` if no script type produces a match. + pub fn from( + wallet_keys: &RootWalletKeys, + output_script: &ScriptBuf, + path: DerivationPath, + network: Network, + ) -> Option { + let script_type = OutputScriptType::check( + wallet_keys, + output_script, + false, + &path, + &network.output_script_support(), + )?; + Some(Self { + script_type, + derivation_path: path, }) - .child(ChildNumber::Normal { index }); - derive_xpubs_with_path(xpubs, ctx, p) + } + + /// Match `output_script` against wallet keys using PSBT derivation metadata. + /// + /// Returns `Ok(None)` if the derivation maps are empty or belong to a different wallet, + /// `Ok(Some(wos))` if the script matches, `Err` if keys are ours but no script type fits. + pub fn from_psbt( + wallet_keys: &RootWalletKeys, + bip32_derivation: &BTreeMap, + tap_key_origins: &BTreeMap< + XOnlyPublicKey, + (Vec, (Fingerprint, DerivationPath)), + >, + has_witness_script: bool, + output_script: &ScriptBuf, + network: Network, + ) -> Result, String> { + if bip32_derivation.is_empty() && tap_key_origins.is_empty() { + return Ok(None); + } + + let belongs_to_wallet = if !bip32_derivation.is_empty() { + bip32_derivation.values().all(|(fp, _)| { + wallet_keys + .xpubs + .iter() + .any(|xpub| xpub.fingerprint() == *fp) + }) + } else { + tap_key_origins.values().all(|(_, (fp, _))| { + wallet_keys + .xpubs + .iter() + .any(|xpub| xpub.fingerprint() == *fp) + }) + }; + + if !belongs_to_wallet { + return Ok(None); + } + + let path = if !bip32_derivation.is_empty() { + bip32_derivation.values().next().map(|(_, path)| path) + } else { + tap_key_origins.values().next().map(|(_, (_, path))| path) + } + .ok_or_else(|| "no derivation paths".to_string())? + .clone(); + + let script_type = OutputScriptType::check( + wallet_keys, + output_script, + has_witness_script, + &path, + &network.output_script_support(), + ) + .ok_or_else(|| { + format!( + "wallet keys match but no script type matches actual script {}", + output_script + ) + })?; + + Ok(Some(Self { + script_type, + derivation_path: path, + })) + } +} + +/// Build a 2-component derivation path `[chain, index]` — the standard form for wallet keys. +pub(crate) fn chain_index_path(chain: u32, index: u32) -> DerivationPath { + DerivationPath::from(vec![ + ChildNumber::Normal { index: chain }, + ChildNumber::Normal { index }, + ]) } #[cfg(test)] mod tests { use super::*; + use crate::fixed_script_wallet::script_id::{Chain, Scope}; use crate::fixed_script_wallet::wallet_keys::tests::get_test_wallet_keys; use crate::Network; @@ -339,8 +394,8 @@ mod tests { fn assert_output_script(keys: &RootWalletKeys, chain: Chain, expected_script: &str) { let scripts = WalletScripts::from_wallet_keys( keys, - chain, - 0, + chain.script_type, + &chain_index_path(chain.value(), 0), &Network::Bitcoin.output_script_support(), ) .unwrap(); @@ -404,27 +459,17 @@ mod tests { p2mr: false, }; - use OutputScriptType::*; - use Scope::*; + use OutputScriptType::{P2sh, P2shP2wsh, P2trLegacy, P2trMusig2, P2wsh}; + let p = &chain_index_path(0, 0); - let result = WalletScripts::from_wallet_keys( - &keys, - Chain::new(P2wsh, External), - 0, - &no_segwit_support, - ); + let result = WalletScripts::from_wallet_keys(&keys, P2wsh, p, &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::new(P2shP2wsh, External), - 0, - &no_segwit_support, - ); + let result = WalletScripts::from_wallet_keys(&keys, P2shP2wsh, p, &no_segwit_support); assert!(result.is_err()); assert!(result .unwrap_err() @@ -438,24 +483,14 @@ mod tests { p2mr: false, }; - let result = WalletScripts::from_wallet_keys( - &keys, - Chain::new(P2trLegacy, External), - 0, - &no_taproot_support, - ); + let result = WalletScripts::from_wallet_keys(&keys, P2trLegacy, p, &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::new(P2trMusig2, External), - 0, - &no_taproot_support, - ); + let result = WalletScripts::from_wallet_keys(&keys, P2trMusig2, p, &no_taproot_support); assert!(result.is_err()); assert!(result .unwrap_err() @@ -463,74 +498,32 @@ 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::new(P2sh, External), - 0, - &no_segwit_support, - ); - assert!(result.is_ok()); + assert!(WalletScripts::from_wallet_keys(&keys, P2sh, p, &no_segwit_support).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::new(P2wsh, External), 0, &doge_support); + let result = WalletScripts::from_wallet_keys(&keys, P2wsh, p, &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::new(P2trLegacy, External), - 0, - <c_support, - ); + let result = WalletScripts::from_wallet_keys(&keys, P2trLegacy, p, <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::new(P2wsh, External), 0, <c_support); - assert!(result.is_ok()); + assert!(WalletScripts::from_wallet_keys(&keys, P2wsh, p, <c_support).is_ok()); - // Bitcoin should support all script types let btc_support = Network::Bitcoin.output_script_support(); - 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()); + assert!(WalletScripts::from_wallet_keys(&keys, P2sh, p, &btc_support).is_ok()); + assert!(WalletScripts::from_wallet_keys(&keys, P2wsh, p, &btc_support).is_ok()); + assert!(WalletScripts::from_wallet_keys(&keys, P2trLegacy, p, &btc_support).is_ok()); + assert!(WalletScripts::from_wallet_keys(&keys, P2trMusig2, p, &btc_support).is_ok()); } #[test] 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 0bfaf6189ac..4c637eb2fc8 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/dimensions.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/dimensions.rs @@ -9,7 +9,8 @@ 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, OutputScriptType}; +use crate::fixed_script_wallet::wallet_scripts::OutputScriptType; +use crate::fixed_script_wallet::Chain; use miniscript::bitcoin::VarInt; use wasm_bindgen::prelude::*; 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 f64851ab27b..b29d216e35a 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -9,8 +9,8 @@ use wasm_bindgen::JsValue; use crate::address::networks::AddressFormat; use crate::error::WasmUtxoError; -use crate::fixed_script_wallet::wallet_scripts::{OutputScriptType, Scope}; -use crate::fixed_script_wallet::{Chain, WalletScripts}; +use crate::fixed_script_wallet::wallet_scripts::{chain_index_path, OutputScriptType}; +use crate::fixed_script_wallet::{Chain, Scope, WalletScripts}; use crate::utxolib_compat::UtxolibNetwork; use crate::wasm::bip32::WasmBIP32; use crate::wasm::ecpair::WasmECPair; @@ -51,8 +51,8 @@ impl FixedScriptWalletNamespace { let wallet_keys = keys.inner(); let scripts = WalletScripts::from_wallet_keys( wallet_keys, - chain, - index, + chain.script_type, + &chain_index_path(chain.value(), index), &network.output_script_support(), )?; Ok(scripts.output_script().to_bytes()) @@ -72,8 +72,8 @@ impl FixedScriptWalletNamespace { .map_err(|e| WasmUtxoError::new(&format!("Invalid chain: {}", e)))?; let scripts = WalletScripts::from_wallet_keys( wallet_keys, - chain, - index, + chain.script_type, + &chain_index_path(chain.value(), index), &network.output_script_support(), )?; let script = scripts.output_script(); @@ -102,8 +102,8 @@ impl FixedScriptWalletNamespace { let wallet_keys = keys.inner(); let scripts = WalletScripts::from_wallet_keys( wallet_keys, - chain, - index, + chain.script_type, + &chain_index_path(chain.value(), index), &network.output_script_support(), )?; Ok(scripts.output_script().to_bytes()) @@ -123,8 +123,8 @@ impl FixedScriptWalletNamespace { .map_err(|e| WasmUtxoError::new(&format!("Invalid chain: {}", e)))?; let scripts = WalletScripts::from_wallet_keys( wallet_keys, - chain, - index, + chain.script_type, + &chain_index_path(chain.value(), index), &network.output_script_support(), )?; let script = scripts.output_script(); @@ -764,10 +764,9 @@ impl BitGoPsbt { sequence: Option, prev_tx: Option>, ) -> Result { - use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::{ - ScriptId, SignPath, SignerKey, - }; + use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::{SignPath, SignerKey}; use crate::fixed_script_wallet::bitgo_psbt::WalletInputOptions; + use crate::fixed_script_wallet::ScriptId; use miniscript::bitcoin::Txid; use std::str::FromStr; diff --git a/packages/wasm-utxo/src/wasm/try_from_js_value.rs b/packages/wasm-utxo/src/wasm/try_from_js_value.rs index cb6bb426125..067343fd4d5 100644 --- a/packages/wasm-utxo/src/wasm/try_from_js_value.rs +++ b/packages/wasm-utxo/src/wasm/try_from_js_value.rs @@ -242,7 +242,7 @@ impl TryFromJsValue for crate::networks::Network { impl TryFromJsValue for crate::fixed_script_wallet::bitgo_psbt::HydrationUnspentInput { fn try_from_js_value(item: &JsValue) -> Result { - use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::ScriptIdWithValue; + use crate::fixed_script_wallet::ScriptIdWithValue; // Read 'value' as BigInt (required) let value_js = js_sys::Reflect::get(item, &"value".into()) diff --git a/packages/wasm-utxo/src/wasm/try_into_js_value.rs b/packages/wasm-utxo/src/wasm/try_into_js_value.rs index 9ba6bba355f..b699d5532ab 100644 --- a/packages/wasm-utxo/src/wasm/try_into_js_value.rs +++ b/packages/wasm-utxo/src/wasm/try_into_js_value.rs @@ -332,6 +332,12 @@ impl TryIntoJsValue for SigningKeysMap { } } +impl TryIntoJsValue for miniscript::bitcoin::bip32::DerivationPath { + fn try_to_js_value(&self) -> Result { + Ok(JsValue::from_str(&self.to_string())) + } +} + impl TryIntoJsValue for crate::fixed_script_wallet::bitgo_psbt::ScriptId { fn try_to_js_value(&self) -> Result { js_obj!( @@ -366,7 +372,8 @@ impl TryIntoJsValue for crate::fixed_script_wallet::bitgo_psbt::ParsedInput { "value" => self.value, "scriptId" => self.script_id, "scriptType" => self.script_type, - "sequence" => self.sequence + "sequence" => self.sequence, + "derivationPath" => self.derivation_path.clone() ) } } @@ -378,7 +385,8 @@ impl TryIntoJsValue for crate::fixed_script_wallet::bitgo_psbt::ParsedOutput { "script" => self.script.clone(), "value" => self.value, "scriptId" => self.script_id, - "paygo" => self.paygo + "paygo" => self.paygo, + "derivationPath" => self.derivation_path.clone() ) } } diff --git a/packages/wasm-utxo/src/wasm/wallet_keys.rs b/packages/wasm-utxo/src/wasm/wallet_keys.rs index 97adfcfc31c..aa552ab0752 100644 --- a/packages/wasm-utxo/src/wasm/wallet_keys.rs +++ b/packages/wasm-utxo/src/wasm/wallet_keys.rs @@ -76,10 +76,18 @@ impl WasmRootWalletKeys { let derivation_paths = [user_derivation, backup_derivation, bitgo_derivation] .iter() .map(|p| { - // Remove leading 'm/' if present and add it back - let p = p.strip_prefix("m/").unwrap_or(p); - DerivationPath::from_str(&format!("m/{}", p)) - .map_err(|e| WasmUtxoError::new(&format!("Invalid derivation prefix: {}", e))) + // Strip leading 'm/' or 'm' to get the relative path component + let rel = p + .strip_prefix("m/") + .or_else(|| p.strip_prefix("m")) + .unwrap_or(p); + if rel.is_empty() { + Ok(DerivationPath::default()) + } else { + DerivationPath::from_str(&format!("m/{}", rel)).map_err(|e| { + WasmUtxoError::new(&format!("Invalid derivation prefix: {}", e)) + }) + } }) .collect::, _>>()? .try_into() diff --git a/packages/wasm-utxo/test/fixedScript/nonStandardPaths.ts b/packages/wasm-utxo/test/fixedScript/nonStandardPaths.ts new file mode 100644 index 00000000000..1339d4212de --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/nonStandardPaths.ts @@ -0,0 +1,178 @@ +/** + * Tests for wallets with nonstandard derivation paths. + * + * Some cold wallets use chain codes that do not follow BitGo convention + * (0/1 = P2sh, 20/21 = P2wsh, etc.) but still produce valid 2-of-3 P2WSH + * scripts. This test verifies that parseOutputsWithWalletKeys and + * parseTransactionWithWalletKeys correctly identify wallet outputs and inputs + * using key-based derivation matching, independent of the chain code value. + * + * A PSBT is constructed programmatically using the WrapPsbt / descriptor API + * with nonstandard chain codes (0 for external, 1 for internal) on a P2WSH + * multisig wallet. + */ + +import assert from "node:assert"; + +import { BIP32Interface } from "@bitgo/utxo-lib"; +import { getKey } from "@bitgo/utxo-lib/dist/src/testutil"; + +import { formatNode } from "../../js/ast/index.js"; +import { Descriptor, Psbt } from "../../js/index.js"; +import { BitGoPsbt, RootWalletKeys } from "../../js/fixedScriptWallet/index.js"; + +// ─── helpers ──────────────────────────────────────────────────────────────── + +function xpubWithChain(k: BIP32Interface, chain: number): string { + return `${k.neutered().toBase58()}/${chain}/*`; +} + +/** Build a derivable 2-of-3 P2WSH descriptor with the given chain code. */ +function makeWshDescriptor( + user: BIP32Interface, + backup: BIP32Interface, + bitgo: BIP32Interface, + chain: number, +): Descriptor { + return Descriptor.fromString( + formatNode({ + wsh: { + multi: [ + 2, + xpubWithChain(user, chain), + xpubWithChain(backup, chain), + xpubWithChain(bitgo, chain), + ], + }, + }), + "derivable", + ); +} + +/** Build a derivable P2WPKH descriptor (for the external output). */ +function makeWpkhDescriptor(k: BIP32Interface): Descriptor { + return Descriptor.fromString(formatNode({ wpkh: `${k.neutered().toBase58()}/*` }), "derivable"); +} + +/** + * Construct a PSBT with nonstandard chain codes using the WrapPsbt descriptor API. + * + * Layout: + * Input 0: P2WSH (2-of-3), chain=nonStdChain, index=inputIndex — wallet input + * Output 0: P2WPKH external payment + * Output 1: P2WSH change, chain=nonStdChangeChain, index=changeIndex — wallet change + */ +function buildNonStandardPsbt( + user: BIP32Interface, + backup: BIP32Interface, + bitgo: BIP32Interface, + external: BIP32Interface, + opts: { + nonStdChain: number; + nonStdChangeChain: number; + inputIndex: number; + changeIndex: number; + inputValue?: bigint; + paymentValue?: bigint; + changeValue?: bigint; + }, +): BitGoPsbt { + const { + nonStdChain, + nonStdChangeChain, + inputIndex, + changeIndex, + inputValue = 10_000_000n, + paymentValue = 6_000_000n, + changeValue = 3_990_000n, + } = opts; + + const inputDescAt = makeWshDescriptor(user, backup, bitgo, nonStdChain).atDerivationIndex( + inputIndex, + ); + const changeDescAt = makeWshDescriptor(user, backup, bitgo, nonStdChangeChain).atDerivationIndex( + changeIndex, + ); + const externalDescAt = makeWpkhDescriptor(external).atDerivationIndex(0); + + const psbt = new Psbt(); + + psbt.addInput( + "0000000000000000000000000000000000000000000000000000000000000001", + 0, + inputValue, + Buffer.from(inputDescAt.scriptPubkey()), + ); + psbt.updateInputWithDescriptor(0, inputDescAt); + + psbt.addOutput(Buffer.from(externalDescAt.scriptPubkey()), paymentValue); + psbt.addOutput(Buffer.from(changeDescAt.scriptPubkey()), changeValue); + psbt.updateOutputWithDescriptor(1, changeDescAt); + + return BitGoPsbt.fromBytes(psbt.serialize(), "bitcoin"); +} + +// ─── tests ─────────────────────────────────────────────────────────────────── + +describe("nonstandard derivation paths", function () { + const user = getKey("user"); + const backup = getKey("backup"); + const bitgo = getKey("bitgo"); + const external = getKey("external"); + + // Nonstandard: P2WSH but with chain codes 0/1 instead of BitGo convention 20/21 + const NONSTANDARD_CHAIN = 0; + const NONSTANDARD_CHANGE_CHAIN = 1; + const INPUT_INDEX = 5; + const CHANGE_INDEX = 2; + + let psbt: BitGoPsbt; + let walletKeys: RootWalletKeys; + + before(function () { + psbt = buildNonStandardPsbt(user, backup, bitgo, external, { + nonStdChain: NONSTANDARD_CHAIN, + nonStdChangeChain: NONSTANDARD_CHANGE_CHAIN, + inputIndex: INPUT_INDEX, + changeIndex: CHANGE_INDEX, + }); + + // xpubs are the signing root — PSBT paths are [chain, index] relative to them + walletKeys = RootWalletKeys.withDerivationPrefixes( + [user.neutered().toBase58(), backup.neutered().toBase58(), bitgo.neutered().toBase58()], + ["", "", ""], + ); + }); + + it("parseOutputsWithWalletKeys identifies change output despite nonstandard chain code", function () { + const outputs = psbt.parseOutputsWithWalletKeys(walletKeys); + + assert.strictEqual(outputs.length, 2); + const [external, change] = outputs; + + assert.strictEqual(external.derivationPath, null, "output 0 should be external"); + + assert.notStrictEqual(change.derivationPath, null, "change output must have derivationPath"); + assert.strictEqual(change.derivationPath, `${NONSTANDARD_CHANGE_CHAIN}/${CHANGE_INDEX}`); + assert.strictEqual(change.scriptId, null, "scriptId must be null (nonstandard chain code)"); + }); + + it("parseTransactionWithWalletKeys identifies input and change output", function () { + const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, { + replayProtection: { publicKeys: [] }, + }); + + assert.strictEqual(parsed.inputs.length, 1); + assert.strictEqual(parsed.outputs.length, 2); + + const [input] = parsed.inputs; + assert.strictEqual(input.scriptType, "p2wsh"); + assert.strictEqual(input.derivationPath, `${NONSTANDARD_CHAIN}/${INPUT_INDEX}`); + assert.strictEqual(input.scriptId, null, "input scriptId must be null (nonstandard chain)"); + + const [, change] = parsed.outputs; + assert.notStrictEqual(change.derivationPath, null); + assert.strictEqual(change.derivationPath, `${NONSTANDARD_CHANGE_CHAIN}/${CHANGE_INDEX}`); + assert.strictEqual(change.scriptId, null); + }); +});