diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index 6a26391..1ab1d2e 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -60,8 +60,31 @@ declare module "./wasm/wasm_utxo.js" { } interface WrapPsbt { + // Signing methods (legacy - kept for backwards compatibility) signWithXprv(this: WrapPsbt, xprv: string): SignPsbtResult; signWithPrv(this: WrapPsbt, prv: Uint8Array): SignPsbtResult; + + // Signing methods (new - using WasmBIP32/WasmECPair) + signAll(this: WrapPsbt, key: WasmBIP32): SignPsbtResult; + signAllWithEcpair(this: WrapPsbt, key: WasmECPair): SignPsbtResult; + + // Introspection methods + inputCount(): number; + outputCount(): number; + getPartialSignatures(inputIndex: number): Array<{ + pubkey: Uint8Array; + signature: Uint8Array; + }>; + hasPartialSignatures(inputIndex: number): boolean; + + // Validation methods + validateSignatureAtInput(inputIndex: number, pubkey: Uint8Array): boolean; + verifySignatureWithKey(inputIndex: number, key: WasmBIP32): boolean; + + // Metadata methods + unsignedTxId(): string; + lockTime(): number; + version(): number; } } 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 086e7dc..862c0cc 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 @@ -2574,16 +2574,42 @@ impl BitGoPsbt { ) -> Result { match self { BitGoPsbt::BitcoinLike(psbt, network) => { + use miniscript::bitcoin::sighash::SighashCache; + let input = &psbt.inputs[input_index]; + let mut cache = SighashCache::new(&psbt.unsigned_tx); // Check for Taproot script path signatures first if !input.tap_script_sigs.is_empty() { - return psbt_wallet_input::verify_taproot_script_signature( + match psbt_wallet_input::verify_taproot_script_signature( secp, psbt, input_index, public_key, - ); + &mut cache, + ) { + Ok(true) => return Ok(true), + Ok(false) => {} + Err(e) => return Err(e), + } + } + + // Check for Taproot key path signature + if input.tap_key_sig.is_some() { + let pk = miniscript::bitcoin::PublicKey::from_slice(&public_key.to_bytes()) + .map_err(|e| format!("Failed to convert public key: {}", e))?; + let (x_only_key, _) = pk.inner.x_only_public_key(); + match psbt_wallet_input::verify_taproot_key_signature( + secp, + psbt, + input_index, + x_only_key, + &mut cache, + ) { + Ok(true) => return Ok(true), + Ok(false) => {} + Err(e) => return Err(e), + } } let fork_id = sighash::get_sighash_fork_id(*network); @@ -2598,17 +2624,43 @@ impl BitGoPsbt { ) } BitGoPsbt::Dash(dash_psbt, network) => { + use miniscript::bitcoin::sighash::SighashCache; + let psbt = &dash_psbt.psbt; let input = &psbt.inputs[input_index]; + let mut cache = SighashCache::new(&psbt.unsigned_tx); // Check for Taproot script path signatures first if !input.tap_script_sigs.is_empty() { - return psbt_wallet_input::verify_taproot_script_signature( + match psbt_wallet_input::verify_taproot_script_signature( secp, psbt, input_index, public_key, - ); + &mut cache, + ) { + Ok(true) => return Ok(true), + Ok(false) => {} + Err(e) => return Err(e), + } + } + + // Check for Taproot key path signature + if input.tap_key_sig.is_some() { + let pk = miniscript::bitcoin::PublicKey::from_slice(&public_key.to_bytes()) + .map_err(|e| format!("Failed to convert public key: {}", e))?; + let (x_only_key, _) = pk.inner.x_only_public_key(); + match psbt_wallet_input::verify_taproot_key_signature( + secp, + psbt, + input_index, + x_only_key, + &mut cache, + ) { + Ok(true) => return Ok(true), + Ok(false) => {} + Err(e) => return Err(e), + } } let fork_id = sighash::get_sighash_fork_id(*network); 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 801027d..094f7d7 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 @@ -131,20 +131,23 @@ pub fn derive_pubkey_from_input( /// - `psbt`: The PSBT containing the transaction and inputs /// - `input_index`: The index of the input to verify /// - `public_key`: The compressed public key to verify the signature for +/// - `cache`: Mutable reference to a SighashCache for computing sighash (can be reused for bulk verification) /// /// # Returns /// - `Ok(true)` if a valid Schnorr signature exists for the public key /// - `Ok(false)` if no signature exists or verification fails /// - `Err(String)` if required data is missing or computation fails -pub fn verify_taproot_script_signature( +pub fn verify_taproot_script_signature< + C: secp256k1::Verification, + T: std::borrow::Borrow, +>( secp: &secp256k1::Secp256k1, psbt: &miniscript::bitcoin::psbt::Psbt, input_index: usize, public_key: miniscript::bitcoin::CompressedPublicKey, + cache: &mut miniscript::bitcoin::sighash::SighashCache, ) -> Result { - use miniscript::bitcoin::{ - hashes::Hash, sighash::Prevouts, sighash::SighashCache, TapLeafHash, XOnlyPublicKey, - }; + use miniscript::bitcoin::{hashes::Hash, sighash::Prevouts, TapLeafHash, XOnlyPublicKey}; let input = &psbt.inputs[input_index]; @@ -160,11 +163,8 @@ pub fn verify_taproot_script_signature( for ((sig_pubkey, leaf_hash), signature) in &input.tap_script_sigs { if sig_pubkey == &x_only_key { // Found a signature for this public key, now verify it - let mut cache = SighashCache::new(&psbt.unsigned_tx); - // Compute taproot script spend sighash - let prevouts = super::p2tr_musig2_input::collect_prevouts(psbt) - .map_err(|e| format!("Failed to collect prevouts: {}", e))?; + let prevouts = collect_prevouts(psbt)?; // Find the script for this leaf hash // tap_scripts is keyed by ControlBlock, so we need to find the matching entry @@ -206,6 +206,97 @@ pub fn verify_taproot_script_signature( Ok(false) } +/// Collect all prevouts (funding outputs) from PSBT inputs +/// +/// This helper extracts the TxOut for each input from either witness_utxo or non_witness_utxo. +/// Required for computing sighashes in taproot transactions. +/// +/// # Arguments +/// - `psbt`: The PSBT containing the inputs +/// +/// # Returns +/// - `Ok(Vec)` with all prevouts +/// - `Err(String)` if any input is missing UTXO data +pub fn collect_prevouts( + psbt: &miniscript::bitcoin::psbt::Psbt, +) -> Result, String> { + let tx = &psbt.unsigned_tx; + psbt.inputs + .iter() + .enumerate() + .map(|(i, input)| { + if let Some(witness_utxo) = &input.witness_utxo { + Ok(witness_utxo.clone()) + } else if let Some(non_witness_utxo) = &input.non_witness_utxo { + let output_index = tx.input[i].previous_output.vout as usize; + non_witness_utxo + .output + .get(output_index) + .cloned() + .ok_or_else(|| format!("Output index {} out of bounds", output_index)) + } else { + Err(format!("Missing UTXO data for input {}", i)) + } + }) + .collect() +} + +/// Verifies a Taproot key path signature for a given x-only public key in a PSBT input +/// +/// # Arguments +/// - `secp`: Secp256k1 context for signature verification +/// - `psbt`: The PSBT containing the transaction and inputs +/// - `input_index`: The index of the input to verify +/// - `x_only_key`: The x-only public key to verify the signature for +/// - `cache`: Mutable reference to a SighashCache for computing sighash (can be reused for bulk verification) +/// +/// # Returns +/// - `Ok(true)` if a valid Schnorr signature exists for the public key +/// - `Ok(false)` if no signature exists or verification fails +/// - `Err(String)` if required data is missing or computation fails +pub fn verify_taproot_key_signature< + C: secp256k1::Verification, + T: std::borrow::Borrow, +>( + secp: &secp256k1::Secp256k1, + psbt: &miniscript::bitcoin::psbt::Psbt, + input_index: usize, + x_only_key: miniscript::bitcoin::XOnlyPublicKey, + cache: &mut miniscript::bitcoin::sighash::SighashCache, +) -> Result { + use miniscript::bitcoin::{hashes::Hash, sighash::Prevouts}; + + let input = &psbt.inputs[input_index]; + + // Check if there's a taproot key path signature + let sig = match &input.tap_key_sig { + Some(sig) => sig, + None => return Ok(false), + }; + + // Verify that the tap_internal_key matches the provided x_only_key + match &input.tap_internal_key { + Some(tap_internal_key) if tap_internal_key == &x_only_key => {} + Some(_) => return Ok(false), // Key mismatch + None => return Ok(false), // No tap_internal_key + } + + // Collect prevouts for taproot sighash + let prevouts = collect_prevouts(psbt)?; + + // Compute taproot key spend sighash + let sighash = cache + .taproot_key_spend_signature_hash(input_index, &Prevouts::All(&prevouts), sig.sighash_type) + .map_err(|e| format!("Failed to compute taproot sighash: {}", e))?; + + // Verify Schnorr signature + let message = secp256k1::Message::from_digest(sighash.to_byte_array()); + match secp.verify_schnorr(&sig.signature, &message, &x_only_key) { + Ok(()) => Ok(true), + Err(_) => Ok(false), + } +} + /// Verifies an ECDSA signature for a given public key in a PSBT input (legacy/SegWit) /// /// # Arguments diff --git a/packages/wasm-utxo/src/wasm/psbt.rs b/packages/wasm-utxo/src/wasm/psbt.rs index 1e78bd1..97bfb45 100644 --- a/packages/wasm-utxo/src/wasm/psbt.rs +++ b/packages/wasm-utxo/src/wasm/psbt.rs @@ -1,5 +1,7 @@ use crate::error::WasmUtxoError; +use crate::wasm::bip32::WasmBIP32; use crate::wasm::descriptor::WrapDescriptorEnum; +use crate::wasm::ecpair::WasmECPair; use crate::wasm::try_into_js_value::TryIntoJsValue; use crate::wasm::WrapDescriptor; use miniscript::bitcoin::bip32::Fingerprint; @@ -260,6 +262,159 @@ impl WrapPsbt { .and_then(|r| r.try_to_js_value()) } + /// Sign all inputs with a WasmBIP32 key + /// + /// This method signs all inputs that match the BIP32 derivation paths in the PSBT. + /// Returns a map of input indices to the public keys that were signed. + /// + /// # Arguments + /// * `key` - The WasmBIP32 key to sign with + /// + /// # Returns + /// A SigningKeysMap converted to JsValue (object mapping input indices to signing keys) + #[wasm_bindgen(js_name = signAll)] + pub fn sign_all(&mut self, key: &WasmBIP32) -> Result { + let xpriv = key.to_xpriv()?; + self.0 + .sign(&xpriv, &Secp256k1::new()) + .map_err(|(_, errors)| { + WasmUtxoError::new(&format!("{} errors: {:?}", errors.len(), errors)) + }) + .and_then(|r| r.try_to_js_value()) + } + + /// Sign all inputs with a WasmECPair key + /// + /// This method signs all inputs using the private key from the ECPair. + /// Returns a map of input indices to the public keys that were signed. + /// + /// # Arguments + /// * `key` - The WasmECPair key to sign with + /// + /// # Returns + /// A SigningKeysMap converted to JsValue (object mapping input indices to signing keys) + #[wasm_bindgen(js_name = signAllWithEcpair)] + pub fn sign_all_with_ecpair(&mut self, key: &WasmECPair) -> Result { + let privkey = key.get_private_key()?; + let secp = Secp256k1::new(); + let private_key = PrivateKey::new(privkey, miniscript::bitcoin::network::Network::Bitcoin); + self.0 + .sign(&SingleKeySigner::from_privkey(private_key, &secp), &secp) + .map_err(|(_r, errors)| { + WasmUtxoError::new(&format!("{} errors: {:?}", errors.len(), errors)) + }) + .and_then(|r| r.try_to_js_value()) + } + + /// Verify a signature at a specific input using a WasmBIP32 key + /// + /// This method verifies if a valid signature exists for the given BIP32 key at the specified input. + /// It handles both ECDSA (legacy/SegWit) and Schnorr (Taproot) signatures. + /// + /// Note: This method checks if the key's public key matches any signature in the input. + /// For proper BIP32 verification, the key should be derived to the correct path first. + /// + /// # Arguments + /// * `input_index` - The index of the input to check + /// * `key` - The WasmBIP32 key to verify against + /// + /// # Returns + /// `true` if a valid signature exists for the key, `false` otherwise + #[wasm_bindgen(js_name = verifySignatureWithKey)] + pub fn verify_signature_with_key( + &self, + input_index: usize, + key: &WasmBIP32, + ) -> Result { + use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input; + use miniscript::bitcoin::{sighash::SighashCache, CompressedPublicKey, PublicKey}; + + let input = self.0.inputs.get(input_index).ok_or_else(|| { + WasmUtxoError::new(&format!("Input index {} out of bounds", input_index)) + })?; + + let secp = Secp256k1::verification_only(); + let xpub = key.to_xpub()?; + + // Get the public key from Xpub (compressed format) + let compressed_pubkey = xpub.to_pub(); + let compressed_public_key = CompressedPublicKey::from_slice(&compressed_pubkey.to_bytes()) + .map_err(|e| { + WasmUtxoError::new(&format!( + "Failed to convert to compressed public key: {}", + e + )) + })?; + let public_key = PublicKey::from_slice(&compressed_public_key.to_bytes()) + .map_err(|e| WasmUtxoError::new(&format!("Failed to convert public key: {}", e)))?; + + // Try ECDSA signature verification first (for legacy/SegWit) + // Use standard Bitcoin (no fork_id) for WASM PSBT + match psbt_wallet_input::verify_ecdsa_signature( + &secp, + &self.0, + input_index, + compressed_public_key, + None, // fork_id: None for standard Bitcoin + ) { + Ok(true) => return Ok(true), + Ok(false) => {} // Continue to try Taproot + Err(e) => { + return Err(WasmUtxoError::new(&format!( + "ECDSA verification error: {}", + e + ))) + } + } + + // Try Schnorr signature verification (for Taproot) + let (x_only_key, _parity) = public_key.inner.x_only_public_key(); + + // Create cache once for reuse across taproot verifications + let mut cache = SighashCache::new(&self.0.unsigned_tx); + + // Check taproot script path signatures + if !input.tap_script_sigs.is_empty() { + match psbt_wallet_input::verify_taproot_script_signature( + &secp, + &self.0, + input_index, + compressed_public_key, + &mut cache, + ) { + Ok(true) => return Ok(true), + Ok(false) => {} // Continue to try key path + Err(e) => { + return Err(WasmUtxoError::new(&format!( + "Taproot script verification error: {}", + e + ))) + } + } + } + + // Check taproot key path signature + match psbt_wallet_input::verify_taproot_key_signature( + &secp, + &self.0, + input_index, + x_only_key, + &mut cache, + ) { + Ok(true) => return Ok(true), + Ok(false) => {} // No signature found + Err(e) => { + return Err(WasmUtxoError::new(&format!( + "Taproot key verification error: {}", + e + ))) + } + } + + // No matching signature found + Ok(false) + } + #[wasm_bindgen(js_name = finalize)] pub fn finalize_mut(&mut self) -> Result<(), WasmUtxoError> { self.0 @@ -268,6 +423,161 @@ impl WrapPsbt { WasmUtxoError::new(&format!("{} errors: {:?}", vec_err.len(), vec_err)) }) } + + /// Get the number of inputs in the PSBT + #[wasm_bindgen(js_name = inputCount)] + pub fn input_count(&self) -> usize { + self.0.inputs.len() + } + + /// Get the number of outputs in the PSBT + #[wasm_bindgen(js_name = outputCount)] + pub fn output_count(&self) -> usize { + self.0.outputs.len() + } + + /// Get partial signatures for an input + /// Returns array of { pubkey: Uint8Array, signature: Uint8Array } + #[wasm_bindgen(js_name = getPartialSignatures)] + pub fn get_partial_signatures(&self, input_index: usize) -> Result { + use crate::wasm::try_into_js_value::{collect_partial_signatures, TryIntoJsValue}; + + let input = self.0.inputs.get(input_index).ok_or_else(|| { + WasmUtxoError::new(&format!("Input index {} out of bounds", input_index)) + })?; + + let signatures = collect_partial_signatures(input); + signatures.try_to_js_value() + } + + /// Check if an input has any partial signatures + #[wasm_bindgen(js_name = hasPartialSignatures)] + pub fn has_partial_signatures(&self, input_index: usize) -> Result { + let input = + self.0.inputs.get(input_index).ok_or_else(|| { + JsError::new(&format!("Input index {} out of bounds", input_index)) + })?; + + Ok(!input.partial_sigs.is_empty() + || !input.tap_script_sigs.is_empty() + || input.tap_key_sig.is_some()) + } + + /// Get the unsigned transaction ID as a hex string + #[wasm_bindgen(js_name = unsignedTxId)] + pub fn unsigned_tx_id(&self) -> String { + self.0.unsigned_tx.compute_txid().to_string() + } + + /// Get the transaction lock time + #[wasm_bindgen(js_name = lockTime)] + pub fn lock_time(&self) -> u32 { + self.0.unsigned_tx.lock_time.to_consensus_u32() + } + + /// Get the transaction version + #[wasm_bindgen(js_name = version)] + pub fn version(&self) -> i32 { + self.0.unsigned_tx.version.0 + } + + /// Validate a signature at a specific input against a pubkey + /// Returns true if the signature is valid + /// + /// This method handles both ECDSA (legacy/SegWit) and Schnorr (Taproot) signatures. + /// The pubkey should be provided as bytes (33 bytes for compressed ECDSA, 32 bytes for x-only Schnorr). + #[wasm_bindgen(js_name = validateSignatureAtInput)] + pub fn validate_signature_at_input( + &self, + input_index: usize, + pubkey: Vec, + ) -> Result { + use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input; + use miniscript::bitcoin::{sighash::SighashCache, CompressedPublicKey, XOnlyPublicKey}; + + let input = + self.0.inputs.get(input_index).ok_or_else(|| { + JsError::new(&format!("Input index {} out of bounds", input_index)) + })?; + + let secp = Secp256k1::verification_only(); + + // Try ECDSA signature verification first (for legacy/SegWit) + if pubkey.len() == 33 { + let compressed_public_key = CompressedPublicKey::from_slice(&pubkey) + .map_err(|e| JsError::new(&format!("Invalid public key: {}", e)))?; + + // Use standard Bitcoin (no fork_id) for WASM PSBT + match psbt_wallet_input::verify_ecdsa_signature( + &secp, + &self.0, + input_index, + compressed_public_key, + None, // fork_id: None for standard Bitcoin + ) { + Ok(true) => return Ok(true), + Ok(false) => {} // Continue to try Taproot if pubkey length allows + Err(e) => return Err(JsError::new(&format!("ECDSA verification error: {}", e))), + } + } + + // Try Schnorr signature verification (for Taproot) + if pubkey.len() == 32 { + let x_only_key = XOnlyPublicKey::from_slice(&pubkey) + .map_err(|e| JsError::new(&format!("Invalid x-only public key: {}", e)))?; + + // Create cache once for reuse across taproot verifications + let mut cache = SighashCache::new(&self.0.unsigned_tx); + + // Check taproot script path signatures + // Convert x_only_key to CompressedPublicKey for the helper function + // We need to prepend 0x02 (even parity) to create a compressed public key + let mut compressed_key_bytes = vec![0x02u8]; + compressed_key_bytes.extend_from_slice(&x_only_key.serialize()); + let compressed_public_key = CompressedPublicKey::from_slice(&compressed_key_bytes) + .map_err(|e| JsError::new(&format!("Failed to convert x-only key: {}", e)))?; + + if !input.tap_script_sigs.is_empty() { + match psbt_wallet_input::verify_taproot_script_signature( + &secp, + &self.0, + input_index, + compressed_public_key, + &mut cache, + ) { + Ok(true) => return Ok(true), + Ok(false) => {} // Continue to try key path + Err(e) => { + return Err(JsError::new(&format!( + "Taproot script verification error: {}", + e + ))) + } + } + } + + // Check taproot key path signature + match psbt_wallet_input::verify_taproot_key_signature( + &secp, + &self.0, + input_index, + x_only_key, + &mut cache, + ) { + Ok(true) => return Ok(true), + Ok(false) => {} // No signature found + Err(e) => { + return Err(JsError::new(&format!( + "Taproot key verification error: {}", + e + ))) + } + } + } + + // No matching signature found + Ok(false) + } } impl Clone for WrapPsbt { 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 2b588fc..fd18fd3 100644 --- a/packages/wasm-utxo/src/wasm/try_into_js_value.rs +++ b/packages/wasm-utxo/src/wasm/try_into_js_value.rs @@ -1,7 +1,7 @@ use crate::error::WasmUtxoError; use js_sys::Array; use miniscript::bitcoin::hashes::{hash160, ripemd160}; -use miniscript::bitcoin::psbt::{SigningKeys, SigningKeysMap}; +use miniscript::bitcoin::psbt::{Input, SigningKeys, SigningKeysMap}; use miniscript::bitcoin::{PublicKey, XOnlyPublicKey}; use miniscript::descriptor::{DescriptorType, ShInner, SortedMultiVec, TapTree, Tr, WshInner}; use miniscript::{ @@ -403,3 +403,55 @@ impl TryIntoJsValue for crate::inscriptions::InscriptionRevealData { ) } } + +/// A partial signature with its associated public key +#[derive(Clone)] +pub struct PartialSignature { + pub pubkey: Vec, + pub signature: Vec, +} + +impl TryIntoJsValue for PartialSignature { + fn try_to_js_value(&self) -> Result { + js_obj!( + "pubkey" => self.pubkey.clone(), + "signature" => self.signature.clone() + ) + } +} + +/// Collect all partial signatures from a PSBT input +/// +/// This helper function extracts ECDSA, Taproot script path, and Taproot key path +/// signatures from a PSBT input and returns them as a vector of PartialSignature structs. +pub fn collect_partial_signatures(input: &Input) -> Vec { + let mut signatures = Vec::new(); + + // Add ECDSA partial signatures + for (pubkey, sig) in &input.partial_sigs { + signatures.push(PartialSignature { + pubkey: pubkey.to_bytes(), + signature: sig.signature.serialize_der().to_vec(), + }); + } + + // Add taproot script path signatures + for ((xonly_pubkey, _leaf_hash), sig) in &input.tap_script_sigs { + signatures.push(PartialSignature { + pubkey: xonly_pubkey.serialize().to_vec(), + signature: sig.signature.serialize().to_vec(), + }); + } + + // Add taproot key path signature if present + if let Some(sig) = &input.tap_key_sig { + if let Some(tap_internal_key) = &input.tap_internal_key { + signatures.push(PartialSignature { + pubkey: tap_internal_key.serialize().to_vec(), + signature: sig.signature.serialize().to_vec(), + }); + } + } + + signatures +} diff --git a/packages/wasm-utxo/test/wrapPsbt.ts b/packages/wasm-utxo/test/wrapPsbt.ts new file mode 100644 index 0000000..8f0c8c8 --- /dev/null +++ b/packages/wasm-utxo/test/wrapPsbt.ts @@ -0,0 +1,547 @@ +/** + * Tests for new WrapPsbt methods introduced in HEAD + * + * Tests cover: + * - signAll / signAllWithEcpair (modern signing API) + * - inputCount / outputCount (introspection) + * - getPartialSignatures / hasPartialSignatures (signature inspection) + * - validateSignatureAtInput / verifySignatureWithKey (signature validation) + * - unsignedTxId / lockTime / version (metadata access) + */ + +import assert from "node:assert"; +import { BIP32Interface } from "@bitgo/utxo-lib"; +import { getKey } from "@bitgo/utxo-lib/dist/src/testutil"; + +import { DescriptorNode, formatNode } from "../js/ast/index.js"; +import { mockPsbtDefault } from "./psbtFromDescriptor.util.js"; +import { Descriptor, Psbt, BIP32, ECPair } from "../js/index.js"; +import { toWrappedPsbt } from "./psbt.util.js"; + +function toKeyWithPath(k: BIP32Interface, path = "*"): string { + return k.neutered().toBase58() + "/" + path; +} + +function toKeyPlainXOnly(k: Buffer): string { + return k.subarray(1).toString("hex"); +} + +function fromNodes(node: DescriptorNode, type: "definite" | "derivable") { + return Descriptor.fromString(formatNode(node), type); +} + +const a = getKey("a"); +const b = getKey("b"); +const c = getKey("c"); +const external = getKey("external"); + +describe("WrapPsbt new methods", function () { + describe("metadata methods", function () { + it("inputCount returns correct count", function () { + const psbt = new Psbt(); + assert.strictEqual(psbt.inputCount(), 0); + + psbt.addInput( + "0000000000000000000000000000000000000000000000000000000000000001", + 0, + 100000n, + Buffer.alloc(34, 0), + ); + assert.strictEqual(psbt.inputCount(), 1); + + psbt.addInput( + "0000000000000000000000000000000000000000000000000000000000000002", + 0, + 200000n, + Buffer.alloc(34, 0), + ); + assert.strictEqual(psbt.inputCount(), 2); + }); + + it("outputCount returns correct count", function () { + const psbt = new Psbt(); + assert.strictEqual(psbt.outputCount(), 0); + + psbt.addOutput(Buffer.alloc(34, 0), 50000n); + assert.strictEqual(psbt.outputCount(), 1); + + psbt.addOutput(Buffer.alloc(34, 0), 40000n); + assert.strictEqual(psbt.outputCount(), 2); + }); + + it("version returns correct value", function () { + const psbt1 = new Psbt(); + assert.strictEqual(psbt1.version(), 2); + + const psbt2 = new Psbt(1); + assert.strictEqual(psbt2.version(), 1); + }); + + it("lockTime returns correct value", function () { + const psbt1 = new Psbt(); + assert.strictEqual(psbt1.lockTime(), 0); + + const psbt2 = new Psbt(2, 500000); + assert.strictEqual(psbt2.lockTime(), 500000); + }); + + it("unsignedTxId returns consistent txid", function () { + const psbt = new Psbt(); + psbt.addInput( + "0000000000000000000000000000000000000000000000000000000000000001", + 0, + 100000n, + Buffer.alloc(34, 0), + ); + psbt.addOutput(Buffer.alloc(34, 0), 90000n); + + const txid1 = psbt.unsignedTxId(); + const txid2 = psbt.unsignedTxId(); + + assert.strictEqual(typeof txid1, "string"); + assert.strictEqual(txid1.length, 64); + assert.strictEqual(txid1, txid2); + }); + }); + + describe("hasPartialSignatures", function () { + it("returns false for unsigned input", function () { + const descriptor = fromNodes( + { wsh: { multi: [2, toKeyWithPath(a), toKeyWithPath(b), toKeyWithPath(c)] } }, + "derivable", + ); + const psbt = mockPsbtDefault({ + descriptorSelf: descriptor, + descriptorOther: Descriptor.fromString( + formatNode({ wpkh: toKeyWithPath(external) }), + "derivable", + ), + }); + + const wrappedPsbt = toWrappedPsbt(psbt); + assert.strictEqual(wrappedPsbt.hasPartialSignatures(0), false); + assert.strictEqual(wrappedPsbt.hasPartialSignatures(1), false); + }); + + it("returns true after signing (ECDSA)", function () { + const descriptor = fromNodes( + { wsh: { multi: [2, toKeyWithPath(a), toKeyWithPath(b), toKeyWithPath(c)] } }, + "derivable", + ); + const psbt = mockPsbtDefault({ + descriptorSelf: descriptor, + descriptorOther: Descriptor.fromString( + formatNode({ wpkh: toKeyWithPath(external) }), + "derivable", + ), + }); + + const wrappedPsbt = toWrappedPsbt(psbt); + wrappedPsbt.signWithXprv(a.toBase58()); + + assert.strictEqual(wrappedPsbt.hasPartialSignatures(0), true); + assert.strictEqual(wrappedPsbt.hasPartialSignatures(1), true); + }); + + it("returns true after signing (Taproot)", function () { + const descriptor = fromNodes( + { tr: [toKeyWithPath(a), [{ pk: toKeyWithPath(b) }, { pk: toKeyWithPath(c) }]] }, + "derivable", + ); + const psbt = mockPsbtDefault({ + descriptorSelf: descriptor, + descriptorOther: Descriptor.fromString( + formatNode({ wpkh: toKeyWithPath(external) }), + "derivable", + ), + }); + + const wrappedPsbt = toWrappedPsbt(psbt); + wrappedPsbt.signWithXprv(a.toBase58()); + + assert.strictEqual(wrappedPsbt.hasPartialSignatures(0), true); + assert.strictEqual(wrappedPsbt.hasPartialSignatures(1), true); + }); + + it("throws for out of bounds input index", function () { + const psbt = new Psbt(); + assert.throws(() => psbt.hasPartialSignatures(0), /out of bounds/); + }); + }); + + describe("getPartialSignatures", function () { + it("returns empty array for unsigned input", function () { + const descriptor = fromNodes( + { wsh: { multi: [2, toKeyWithPath(a), toKeyWithPath(b), toKeyWithPath(c)] } }, + "derivable", + ); + const psbt = mockPsbtDefault({ + descriptorSelf: descriptor, + descriptorOther: Descriptor.fromString( + formatNode({ wpkh: toKeyWithPath(external) }), + "derivable", + ), + }); + + const wrappedPsbt = toWrappedPsbt(psbt); + const sigs = wrappedPsbt.getPartialSignatures(0); + + assert.ok(Array.isArray(sigs)); + assert.strictEqual(sigs.length, 0); + }); + + it("returns signatures after signing (ECDSA)", function () { + const descriptor = fromNodes( + { wsh: { multi: [2, toKeyWithPath(a), toKeyWithPath(b), toKeyWithPath(c)] } }, + "derivable", + ); + const psbt = mockPsbtDefault({ + descriptorSelf: descriptor, + descriptorOther: Descriptor.fromString( + formatNode({ wpkh: toKeyWithPath(external) }), + "derivable", + ), + }); + + const wrappedPsbt = toWrappedPsbt(psbt); + wrappedPsbt.signWithXprv(a.toBase58()); + + const sigs = wrappedPsbt.getPartialSignatures(0); + assert.ok(Array.isArray(sigs)); + assert.strictEqual(sigs.length, 1); + assert.ok(sigs[0].pubkey instanceof Uint8Array); + assert.ok(sigs[0].signature instanceof Uint8Array); + assert.strictEqual(sigs[0].pubkey.length, 33); + }); + + it("returns multiple signatures after multiple signings", function () { + const descriptor = fromNodes( + { wsh: { multi: [2, toKeyWithPath(a), toKeyWithPath(b), toKeyWithPath(c)] } }, + "derivable", + ); + const psbt = mockPsbtDefault({ + descriptorSelf: descriptor, + descriptorOther: Descriptor.fromString( + formatNode({ wpkh: toKeyWithPath(external) }), + "derivable", + ), + }); + + const wrappedPsbt = toWrappedPsbt(psbt); + wrappedPsbt.signWithXprv(a.toBase58()); + wrappedPsbt.signWithXprv(b.toBase58()); + + const sigs = wrappedPsbt.getPartialSignatures(0); + assert.strictEqual(sigs.length, 2); + }); + + it("throws for out of bounds input index", function () { + const psbt = new Psbt(); + assert.throws(() => psbt.getPartialSignatures(0), /out of bounds/); + }); + }); + + describe("signAll with BIP32", function () { + it("signs all inputs with BIP32 key (ECDSA)", function () { + const descriptor = fromNodes( + { wsh: { multi: [2, toKeyWithPath(a), toKeyWithPath(b), toKeyWithPath(c)] } }, + "derivable", + ); + const psbt = mockPsbtDefault({ + descriptorSelf: descriptor, + descriptorOther: Descriptor.fromString( + formatNode({ wpkh: toKeyWithPath(external) }), + "derivable", + ), + }); + + const wrappedPsbt = toWrappedPsbt(psbt); + const bip32Key = BIP32.fromBase58(a.toBase58()); + + // Pass the underlying WASM instance + const result = wrappedPsbt.signAll(bip32Key.wasm); + + assert.ok(result); + assert.ok(0 in result); + assert.ok(1 in result); + assert.strictEqual(wrappedPsbt.hasPartialSignatures(0), true); + assert.strictEqual(wrappedPsbt.hasPartialSignatures(1), true); + }); + + it("signs all inputs with BIP32 key (Taproot)", function () { + const descriptor = fromNodes( + { tr: [toKeyWithPath(a), [{ pk: toKeyWithPath(b) }, { pk: toKeyWithPath(c) }]] }, + "derivable", + ); + const psbt = mockPsbtDefault({ + descriptorSelf: descriptor, + descriptorOther: Descriptor.fromString( + formatNode({ wpkh: toKeyWithPath(external) }), + "derivable", + ), + }); + + const wrappedPsbt = toWrappedPsbt(psbt); + const bip32Key = BIP32.fromBase58(a.toBase58()); + + // Pass the underlying WASM instance + const result = wrappedPsbt.signAll(bip32Key.wasm); + + assert.ok(result); + assert.strictEqual(wrappedPsbt.hasPartialSignatures(0), true); + assert.strictEqual(wrappedPsbt.hasPartialSignatures(1), true); + }); + }); + + describe("signAllWithEcpair", function () { + it("signs inputs with ECPair key", function () { + const descriptor = fromNodes( + { + tr: [ + toKeyPlainXOnly(external.publicKey), + [ + { pk: toKeyPlainXOnly(external.publicKey) }, + { + or_b: [ + { pk: toKeyPlainXOnly(external.publicKey) }, + { "s:pk": toKeyPlainXOnly(a.publicKey) }, + ], + }, + ], + ], + }, + "definite", + ); + const psbt = mockPsbtDefault({ + descriptorSelf: descriptor, + descriptorOther: Descriptor.fromString( + formatNode({ wpkh: toKeyWithPath(external) }), + "derivable", + ), + }); + + const wrappedPsbt = toWrappedPsbt(psbt); + assert(a.privateKey); + const ecpair = ECPair.fromPrivateKey(a.privateKey); + + // Pass the underlying WASM instance + const result = wrappedPsbt.signAllWithEcpair(ecpair.wasm); + + assert.ok(result); + }); + }); + + describe("verifySignatureWithKey", function () { + it("returns false for unsigned input", function () { + const descriptor = fromNodes( + { wsh: { multi: [2, toKeyWithPath(a), toKeyWithPath(b), toKeyWithPath(c)] } }, + "derivable", + ); + const psbt = mockPsbtDefault({ + descriptorSelf: descriptor, + descriptorOther: Descriptor.fromString( + formatNode({ wpkh: toKeyWithPath(external) }), + "derivable", + ), + }); + + const wrappedPsbt = toWrappedPsbt(psbt); + const bip32Key = BIP32.fromBase58(a.neutered().toBase58()); + + // Pass the underlying WASM instance + assert.strictEqual(wrappedPsbt.verifySignatureWithKey(0, bip32Key.wasm), false); + }); + + it("returns true for signed input with matching key (ECDSA)", function () { + const descriptor = fromNodes( + { wsh: { multi: [2, toKeyWithPath(a), toKeyWithPath(b), toKeyWithPath(c)] } }, + "derivable", + ); + const psbt = mockPsbtDefault({ + descriptorSelf: descriptor, + descriptorOther: Descriptor.fromString( + formatNode({ wpkh: toKeyWithPath(external) }), + "derivable", + ), + }); + + const wrappedPsbt = toWrappedPsbt(psbt); + wrappedPsbt.signWithXprv(a.toBase58()); + + // Need to derive to the correct path for verification + const derivedKey = BIP32.fromBase58(a.derive(0).neutered().toBase58()); + // Pass the underlying WASM instance + assert.strictEqual(wrappedPsbt.verifySignatureWithKey(0, derivedKey.wasm), true); + }); + + it("returns false for non-matching key", function () { + const descriptor = fromNodes( + { wsh: { multi: [2, toKeyWithPath(a), toKeyWithPath(b), toKeyWithPath(c)] } }, + "derivable", + ); + const psbt = mockPsbtDefault({ + descriptorSelf: descriptor, + descriptorOther: Descriptor.fromString( + formatNode({ wpkh: toKeyWithPath(external) }), + "derivable", + ), + }); + + const wrappedPsbt = toWrappedPsbt(psbt); + wrappedPsbt.signWithXprv(a.toBase58()); + + // Use a different key (b derived) - should not match signature from a + const derivedKey = BIP32.fromBase58(b.derive(0).neutered().toBase58()); + // Pass the underlying WASM instance + assert.strictEqual(wrappedPsbt.verifySignatureWithKey(0, derivedKey.wasm), false); + }); + + it("returns true for Taproot script path signature", function () { + // For Taproot, test script path signing with key 'b' (not key path with 'a') + // since key path verification requires tweaked key handling + const descriptor = fromNodes( + { tr: [toKeyWithPath(a), [{ pk: toKeyWithPath(b) }, { pk: toKeyWithPath(c) }]] }, + "derivable", + ); + const psbt = mockPsbtDefault({ + descriptorSelf: descriptor, + descriptorOther: Descriptor.fromString( + formatNode({ wpkh: toKeyWithPath(external) }), + "derivable", + ), + }); + + const wrappedPsbt = toWrappedPsbt(psbt); + // Sign with 'b' which creates a script path signature + wrappedPsbt.signWithXprv(b.toBase58()); + + const derivedKey = BIP32.fromBase58(b.derive(0).neutered().toBase58()); + // Pass the underlying WASM instance + assert.strictEqual(wrappedPsbt.verifySignatureWithKey(0, derivedKey.wasm), true); + }); + + it("throws for out of bounds input index", function () { + const psbt = new Psbt(); + const bip32Key = BIP32.fromBase58(a.neutered().toBase58()); + // Pass the underlying WASM instance + assert.throws(() => psbt.verifySignatureWithKey(0, bip32Key.wasm), /out of bounds/); + }); + }); + + describe("validateSignatureAtInput", function () { + it("returns false for unsigned input", function () { + const descriptor = fromNodes( + { wsh: { multi: [2, toKeyWithPath(a), toKeyWithPath(b), toKeyWithPath(c)] } }, + "derivable", + ); + const psbt = mockPsbtDefault({ + descriptorSelf: descriptor, + descriptorOther: Descriptor.fromString( + formatNode({ wpkh: toKeyWithPath(external) }), + "derivable", + ), + }); + + const wrappedPsbt = toWrappedPsbt(psbt); + const pubkey = a.derive(0).publicKey; + + assert.strictEqual(wrappedPsbt.validateSignatureAtInput(0, pubkey), false); + }); + + it("returns true for signed input with matching pubkey (ECDSA)", function () { + const descriptor = fromNodes( + { wsh: { multi: [2, toKeyWithPath(a), toKeyWithPath(b), toKeyWithPath(c)] } }, + "derivable", + ); + const psbt = mockPsbtDefault({ + descriptorSelf: descriptor, + descriptorOther: Descriptor.fromString( + formatNode({ wpkh: toKeyWithPath(external) }), + "derivable", + ), + }); + + const wrappedPsbt = toWrappedPsbt(psbt); + wrappedPsbt.signWithXprv(a.toBase58()); + + const pubkey = a.derive(0).publicKey; + assert.strictEqual(wrappedPsbt.validateSignatureAtInput(0, pubkey), true); + }); + + it("returns false for non-matching pubkey", function () { + const descriptor = fromNodes( + { wsh: { multi: [2, toKeyWithPath(a), toKeyWithPath(b), toKeyWithPath(c)] } }, + "derivable", + ); + const psbt = mockPsbtDefault({ + descriptorSelf: descriptor, + descriptorOther: Descriptor.fromString( + formatNode({ wpkh: toKeyWithPath(external) }), + "derivable", + ), + }); + + const wrappedPsbt = toWrappedPsbt(psbt); + wrappedPsbt.signWithXprv(a.toBase58()); + + const pubkey = b.derive(0).publicKey; + assert.strictEqual(wrappedPsbt.validateSignatureAtInput(0, pubkey), false); + }); + + it("validates Taproot script path with x-only pubkey", function () { + // For Taproot, test script path validation with key 'b' + // since key path verification requires tweaked key handling + const descriptor = fromNodes( + { tr: [toKeyWithPath(a), [{ pk: toKeyWithPath(b) }, { pk: toKeyWithPath(c) }]] }, + "derivable", + ); + const psbt = mockPsbtDefault({ + descriptorSelf: descriptor, + descriptorOther: Descriptor.fromString( + formatNode({ wpkh: toKeyWithPath(external) }), + "derivable", + ), + }); + + const wrappedPsbt = toWrappedPsbt(psbt); + // Sign with 'b' which creates a script path signature + wrappedPsbt.signWithXprv(b.toBase58()); + + // Use x-only pubkey (32 bytes, without the prefix) + const xOnlyPubkey = b.derive(0).publicKey.subarray(1); + assert.strictEqual(xOnlyPubkey.length, 32); + assert.strictEqual(wrappedPsbt.validateSignatureAtInput(0, xOnlyPubkey), true); + }); + + it("throws for out of bounds input index", function () { + const psbt = new Psbt(); + assert.throws(() => psbt.validateSignatureAtInput(0, Buffer.alloc(33)), /out of bounds/); + }); + }); + + describe("clone", function () { + it("creates independent copy", function () { + const psbt1 = new Psbt(2, 100); + psbt1.addInput( + "0000000000000000000000000000000000000000000000000000000000000001", + 0, + 100000n, + Buffer.alloc(34, 0), + ); + psbt1.addOutput(Buffer.alloc(34, 0), 90000n); + + const psbt2 = psbt1.clone(); + + assert.strictEqual(psbt1.inputCount(), psbt2.inputCount()); + assert.strictEqual(psbt1.outputCount(), psbt2.outputCount()); + assert.strictEqual(psbt1.version(), psbt2.version()); + assert.strictEqual(psbt1.lockTime(), psbt2.lockTime()); + assert.strictEqual(psbt1.unsignedTxId(), psbt2.unsignedTxId()); + + // Modifying one should not affect the other + psbt2.addOutput(Buffer.alloc(34, 0), 10000n); + assert.strictEqual(psbt1.outputCount(), 1); + assert.strictEqual(psbt2.outputCount(), 2); + }); + }); +});