diff --git a/packages/wasm-utxo/cli/src/parse/node.rs b/packages/wasm-utxo/cli/src/parse/node.rs index 8a189c0f..da8db544 100644 --- a/packages/wasm-utxo/cli/src/parse/node.rs +++ b/packages/wasm-utxo/cli/src/parse/node.rs @@ -3,7 +3,7 @@ use bitcoin::consensus::Decodable; use bitcoin::hashes::Hash; use bitcoin::psbt::Psbt; use bitcoin::{Network, ScriptBuf, Transaction}; -use wasm_utxo::bitgo_psbt::{ +use wasm_utxo::fixed_script_wallet::bitgo_psbt::{ p2tr_musig2_input::{Musig2PartialSig, Musig2Participants, Musig2PubNonce}, BitGoKeyValue, ProprietaryKeySubtype, BITGO, }; @@ -56,7 +56,7 @@ fn musig2_participants_to_node(participants: &Musig2Participants) -> Node { let mut participants_node = Node::new("participant_pub_keys", Primitive::U64(2)); for (i, pub_key) in participants.participant_pub_keys.iter().enumerate() { - let pub_key_vec: Vec = pub_key.to_bytes().to_vec(); + let pub_key_vec: Vec = pub_key.to_bytes().as_slice().to_vec(); participants_node.add_child(Node::new( format!("participant_{}", i), Primitive::Buffer(pub_key_vec), diff --git a/packages/wasm-utxo/js/fixedScriptWallet.ts b/packages/wasm-utxo/js/fixedScriptWallet.ts index 65641a20..dac97372 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet.ts @@ -1,8 +1,11 @@ import { FixedScriptWalletNamespace } from "./wasm/wasm_utxo"; -import type { UtxolibNetwork, UtxolibRootWalletKeys } from "./utxolibCompat"; +import type { UtxolibName, UtxolibNetwork, UtxolibRootWalletKeys } from "./utxolibCompat"; +import type { CoinName } from "./coinName"; import { Triple } from "./triple"; import { AddressFormat } from "./address"; +export type NetworkName = UtxolibName | CoinName; + export type WalletKeys = /** Just an xpub triple, will assume default derivation prefixes */ | Triple @@ -42,3 +45,65 @@ export function address( ): string { return FixedScriptWalletNamespace.address(keys, chain, index, network, addressFormat); } + +type ReplayProtection = + | { + outputScripts: Uint8Array[]; + } + | { + addresses: string[]; + }; + +export type ScriptId = { chain: number; index: number }; + +export type ParsedInput = { + address?: string; + script: Uint8Array; + value: bigint; + scriptId: ScriptId | undefined; +}; + +export type ParsedOutput = { + address?: string; + script: Uint8Array; + value: bigint; + scriptId?: ScriptId; +}; + +export type ParsedTransaction = { + inputs: ParsedInput[]; + outputs: ParsedOutput[]; + spendAmount: bigint; + minerFee: bigint; + virtualSize: number; +}; + +import { BitGoPsbt as WasmBitGoPsbt } from "./wasm/wasm_utxo"; + +export class BitGoPsbt { + private constructor(private wasm: WasmBitGoPsbt) {} + + /** + * Deserialize a PSBT from bytes + * @param bytes - The PSBT bytes + * @param network - The network to use for deserialization (either utxolib name like "bitcoin" or coin name like "btc") + * @returns A BitGoPsbt instance + */ + static fromBytes(bytes: Uint8Array, network: NetworkName): BitGoPsbt { + const wasm = WasmBitGoPsbt.fromBytes(bytes, network); + return new BitGoPsbt(wasm); + } + + /** + * Parse transaction with wallet keys to identify wallet inputs/outputs + * @param walletKeys - The wallet keys to use for identification + * @param replayProtection - Scripts that are allowed as inputs without wallet validation + * @returns Parsed transaction information + */ + parseTransactionWithWalletKeys( + walletKeys: WalletKeys, + replayProtection: ReplayProtection, + ): ParsedTransaction { + return this.wasm.parseTransactionWithWalletKeys(walletKeys, replayProtection); + } +} diff --git a/packages/wasm-utxo/js/utxolibCompat.ts b/packages/wasm-utxo/js/utxolibCompat.ts index b4a65734..6810d6a2 100644 --- a/packages/wasm-utxo/js/utxolibCompat.ts +++ b/packages/wasm-utxo/js/utxolibCompat.ts @@ -2,6 +2,29 @@ import type { AddressFormat } from "./address"; import { Triple } from "./triple"; import { UtxolibCompatNamespace } from "./wasm/wasm_utxo"; +export type UtxolibName = + | "bitcoin" + | "testnet" + | "bitcoinTestnet4" + | "bitcoinPublicSignet" + | "bitcoinBitGoSignet" + | "bitcoincash" + | "bitcoincashTestnet" + | "ecash" + | "ecashTest" + | "bitcoingold" + | "bitcoingoldTestnet" + | "bitcoinsv" + | "bitcoinsvTestnet" + | "dash" + | "dashTest" + | "dogecoin" + | "dogecoinTest" + | "litecoin" + | "litecoinTest" + | "zcash" + | "zcashTest"; + export type BIP32Interface = { network: { bip32: { diff --git a/packages/wasm-utxo/src/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs similarity index 77% rename from packages/wasm-utxo/src/bitgo_psbt/mod.rs rename to packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs index 4e3b4181..6a59d6bd 100644 --- a/packages/wasm-utxo/src/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs @@ -7,14 +7,16 @@ pub mod p2tr_musig2_input; #[cfg(test)] mod p2tr_musig2_input_utxolib; mod propkv; +pub mod psbt_wallet_input; +pub mod psbt_wallet_output; mod sighash; mod zcash_psbt; -use crate::{bitgo_psbt::zcash_psbt::ZcashPsbt, networks::Network}; - +use crate::Network; use miniscript::bitcoin::{psbt::Psbt, secp256k1, CompressedPublicKey}; pub use propkv::{BitGoKeyValue, ProprietaryKeySubtype, BITGO}; pub use sighash::validate_sighash_type; +use zcash_psbt::ZcashPsbt; #[derive(Debug)] pub enum DeserializeError { @@ -94,6 +96,70 @@ pub enum BitGoPsbt { Zcash(ZcashPsbt, Network), } +// Re-export types from submodules for convenience +pub use psbt_wallet_input::{ParsedInput, ScriptId}; +pub use psbt_wallet_output::ParsedOutput; + +/// Parsed transaction with wallet information +#[derive(Debug, Clone)] +pub struct ParsedTransaction { + pub inputs: Vec, + pub outputs: Vec, + pub spend_amount: u64, + pub miner_fee: u64, + pub virtual_size: u32, +} + +/// Error type for transaction parsing +#[derive(Debug)] +pub enum ParseTransactionError { + /// Failed to parse input + Input { + index: usize, + error: psbt_wallet_input::ParseInputError, + }, + /// Input value overflow when adding to total + InputValueOverflow { index: usize }, + /// Failed to parse output + Output { + index: usize, + error: psbt_wallet_output::ParseOutputError, + }, + /// Output value overflow when adding to total + OutputValueOverflow { index: usize }, + /// Spend amount overflow + SpendAmountOverflow { index: usize }, + /// Fee calculation error (outputs exceed inputs) + FeeCalculation, +} + +impl std::fmt::Display for ParseTransactionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ParseTransactionError::Input { index, error } => { + write!(f, "Input {}: {}", index, error) + } + ParseTransactionError::InputValueOverflow { index } => { + write!(f, "Input {}: value overflow", index) + } + ParseTransactionError::Output { index, error } => { + write!(f, "Output {}: {}", index, error) + } + ParseTransactionError::OutputValueOverflow { index } => { + write!(f, "Output {}: value overflow", index) + } + ParseTransactionError::SpendAmountOverflow { index } => { + write!(f, "Output {}: spend amount overflow", index) + } + ParseTransactionError::FeeCalculation => { + write!(f, "Fee calculation error: outputs exceed inputs") + } + } + } +} + +impl std::error::Error for ParseTransactionError {} + impl BitGoPsbt { /// Deserialize a PSBT from bytes, using network-specific logic pub fn deserialize(psbt_bytes: &[u8], network: Network) -> Result { @@ -396,6 +462,107 @@ impl BitGoPsbt { } } } + + /// Parse transaction with wallet keys to identify wallet inputs/outputs and calculate metrics + /// + /// # Arguments + /// - `wallet_keys`: The wallet's root keys for deriving scripts + /// - `replay_protection`: Scripts that are allowed as inputs without wallet validation + /// + /// # Returns + /// - `Ok(ParsedTransaction)` with parsed inputs, outputs, spend amount, fee, and size + /// - `Err(ParseTransactionError)` if input validation fails or required data is missing + pub fn parse_transaction_with_wallet_keys( + &self, + wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, + replay_protection: &psbt_wallet_input::ReplayProtection, + ) -> Result { + let psbt = self.psbt(); + let network = self.network(); + + // Parse inputs + let mut parsed_inputs = Vec::new(); + let mut total_input_value = 0u64; + + for (input_index, (tx_input, psbt_input)) in psbt + .unsigned_tx + .input + .iter() + .zip(psbt.inputs.iter()) + .enumerate() + { + // Parse the input + let parsed_input = ParsedInput::parse( + psbt_input, + tx_input, + wallet_keys, + replay_protection, + network, + ) + .map_err(|error| ParseTransactionError::Input { + index: input_index, + error, + })?; + + // Add value to total, checking for overflow + total_input_value = total_input_value + .checked_add(parsed_input.value) + .ok_or(ParseTransactionError::InputValueOverflow { index: input_index })?; + + parsed_inputs.push(parsed_input); + } + + // Parse outputs + let mut parsed_outputs = Vec::new(); + let mut total_output_value = 0u64; + let mut spend_amount = 0u64; + + for (output_index, tx_output) in psbt.unsigned_tx.output.iter().enumerate() { + let psbt_output = &psbt.outputs[output_index]; + + total_output_value = total_output_value + .checked_add(tx_output.value.to_sat()) + .ok_or(ParseTransactionError::OutputValueOverflow { + index: output_index, + })?; + + // Parse the output + let parsed_output = ParsedOutput::parse(psbt_output, tx_output, wallet_keys, network) + .map_err(|error| ParseTransactionError::Output { + index: output_index, + error, + })?; + + // If this is an external output, add to spend amount + if parsed_output.is_external() { + spend_amount = spend_amount.checked_add(tx_output.value.to_sat()).ok_or( + ParseTransactionError::SpendAmountOverflow { + index: output_index, + }, + )?; + } + + parsed_outputs.push(parsed_output); + } + + // Calculate miner fee + let miner_fee = total_input_value + .checked_sub(total_output_value) + .ok_or(ParseTransactionError::FeeCalculation)?; + + // Calculate virtual size from unsigned transaction weight + // TODO: Consider using finalized transaction size estimate for more accurate fee calculation + let weight = psbt.unsigned_tx.weight(); + let virtual_size = weight.to_vbytes_ceil(); + + Ok(ParsedTransaction { + inputs: parsed_inputs, + outputs: parsed_outputs, + spend_amount, + miner_fee, + virtual_size: virtual_size as u32, + }) + } } #[cfg(test)] @@ -883,6 +1050,124 @@ mod tests { ); }, ignore: [BitcoinGold, BitcoinCash, Ecash, Zcash]); + crate::test_psbt_fixtures!(test_parse_transaction_with_wallet_keys, network, format, { + // Load fixture and get PSBT + let fixture = fixtures::load_psbt_fixture_with_format( + network.to_utxolib_name(), + fixtures::SignatureState::Unsigned, + format, + ) + .expect("Failed to load fixture"); + + let bitgo_psbt = fixture + .to_bitgo_psbt(network) + .expect("Failed to convert to BitGo PSBT"); + + // Get wallet keys from fixture + let wallet_xprv = fixture + .get_wallet_xprvs() + .expect("Failed to get wallet keys"); + let wallet_keys = wallet_xprv.to_root_wallet_keys(); + + // Create replay protection with the replay protection script from fixture + let replay_protection = psbt_wallet_input::ReplayProtection::new(vec![ + miniscript::bitcoin::ScriptBuf::from_hex("a91420b37094d82a513451ff0ccd9db23aba05bc5ef387") + .expect("Failed to parse replay protection output script"), + ]); + + // Parse the transaction + let parsed = bitgo_psbt + .parse_transaction_with_wallet_keys(&wallet_keys, &replay_protection) + .expect("Failed to parse transaction"); + + // Basic validations + assert!(!parsed.inputs.is_empty(), "Should have at least one input"); + assert!(!parsed.outputs.is_empty(), "Should have at least one output"); + + // Verify at least one replay protection input exists + let replay_protection_inputs = parsed + .inputs + .iter() + .filter(|i| i.script_id.is_none()) + .count(); + assert!( + replay_protection_inputs > 0, + "Should have at least one replay protection input" + ); + + // Verify at least one wallet input exists + let wallet_inputs = parsed + .inputs + .iter() + .filter(|i| i.script_id.is_some()) + .count(); + assert!( + wallet_inputs > 0, + "Should have at least one wallet input" + ); + + // Count internal (wallet) and external outputs + let internal_outputs = parsed + .outputs + .iter() + .filter(|o| o.script_id.is_some()) + .count(); + let external_outputs = parsed + .outputs + .iter() + .filter(|o| o.script_id.is_none()) + .count(); + + assert_eq!( + internal_outputs + external_outputs, + parsed.outputs.len(), + "All outputs should be either internal or external" + ); + + // Verify spend amount only includes external outputs + let calculated_spend_amount: u64 = parsed + .outputs + .iter() + .filter(|o| o.script_id.is_none()) + .map(|o| o.value) + .sum(); + assert_eq!( + parsed.spend_amount, calculated_spend_amount, + "Spend amount should equal sum of external output values" + ); + + // Verify total values + let total_input_value: u64 = parsed.inputs.iter().map(|i| i.value).sum(); + let total_output_value: u64 = parsed.outputs.iter().map(|o| o.value).sum(); + + assert_eq!( + parsed.miner_fee, + total_input_value - total_output_value, + "Miner fee should equal inputs minus outputs" + ); + + // Verify virtual size is reasonable + assert!( + parsed.virtual_size > 0, + "Virtual size should be greater than 0" + ); + + // Verify all outputs are internal (fixtures have no external outputs) + assert_eq!( + external_outputs, 0, + "Test fixtures should have no external outputs" + ); + assert_eq!( + internal_outputs, + parsed.outputs.len(), + "All outputs should be internal" + ); + assert_eq!( + parsed.spend_amount, 0, + "Spend amount should be 0 when all outputs are internal" + ); + }, ignore: [BitcoinGold, BitcoinCash, Ecash, Zcash]); + #[test] fn test_serialize_bitcoin_psbt() { // Test that Bitcoin-like PSBTs can be serialized diff --git a/packages/wasm-utxo/src/bitgo_psbt/p2tr_musig2_input.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/p2tr_musig2_input.rs similarity index 99% rename from packages/wasm-utxo/src/bitgo_psbt/p2tr_musig2_input.rs rename to packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/p2tr_musig2_input.rs index f2d05b09..0be8b31d 100644 --- a/packages/wasm-utxo/src/bitgo_psbt/p2tr_musig2_input.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/p2tr_musig2_input.rs @@ -4,11 +4,9 @@ //! key-values in PSBTs, following the format specified in: //! https://gist.github.com/sanket1729/4b525c6049f4d9e034d27368c49f28a6 -use crate::bitgo_psbt::propkv::{find_kv, is_musig2_key, BitGoKeyValue}; -use crate::fixed_script_wallet::bitgo_musig::key_agg_p2tr_musig2; - -use super::propkv::ProprietaryKeySubtype; +use super::propkv::{find_kv, is_musig2_key, BitGoKeyValue, ProprietaryKeySubtype}; use crate::bitcoin::{key::UntweakedPublicKey, CompressedPublicKey}; +use crate::fixed_script_wallet::wallet_scripts::bitgo_musig::key_agg_p2tr_musig2; use miniscript::bitcoin::hashes::{hex, Hash}; use miniscript::bitcoin::{ bip32::{KeySource, Xpriv, Xpub}, @@ -17,6 +15,11 @@ use miniscript::bitcoin::{ }; use musig2::PubNonce; +#[cfg(test)] +use super::BitGoPsbt; +#[cfg(test)] +use crate::fixed_script_wallet::test_utils::fixtures::XprvTriple; + pub type TapKeyOrigins = std::collections::BTreeMap, KeySource)>; pub fn derive_xpriv_for_input_tap( @@ -1030,9 +1033,9 @@ impl Musig2Input { /// * `input_index` - Index of the MuSig2 input #[cfg(test)] pub fn assert_set_nonce_and_sign_musig2_keypath( - xpriv_triple: &crate::fixed_script_wallet::test_utils::fixtures::XprvTriple, - unsigned_bitgo_psbt: &mut crate::bitgo_psbt::BitGoPsbt, - halfsigned_bitgo_psbt: &crate::bitgo_psbt::BitGoPsbt, + xpriv_triple: &XprvTriple, + unsigned_bitgo_psbt: &mut BitGoPsbt, + halfsigned_bitgo_psbt: &BitGoPsbt, input_index: usize, ) -> Result<(), String> { // Test 1: Functional API (utxolib-compatible, fixture-validated) @@ -1074,8 +1077,8 @@ pub fn assert_set_nonce_and_sign_musig2_keypath( /// * `input_index` - Index of the MuSig2 input #[cfg(test)] pub fn assert_set_nonce_and_sign_musig2_keypath_state_machine( - xpriv_triple: &crate::fixed_script_wallet::test_utils::fixtures::XprvTriple, - unsigned_bitgo_psbt: &mut crate::bitgo_psbt::BitGoPsbt, + xpriv_triple: &XprvTriple, + unsigned_bitgo_psbt: &mut BitGoPsbt, input_index: usize, ) -> Result<(), String> { // Verify this is actually a MuSig2 input diff --git a/packages/wasm-utxo/src/bitgo_psbt/p2tr_musig2_input_utxolib.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/p2tr_musig2_input_utxolib.rs similarity index 99% rename from packages/wasm-utxo/src/bitgo_psbt/p2tr_musig2_input_utxolib.rs rename to packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/p2tr_musig2_input_utxolib.rs index 6b60e2b2..53c327a5 100644 --- a/packages/wasm-utxo/src/bitgo_psbt/p2tr_musig2_input_utxolib.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/p2tr_musig2_input_utxolib.rs @@ -7,9 +7,12 @@ //! For production use, prefer the State-Machine API in the parent module which //! provides better protection against nonce reuse. -use super::p2tr_musig2_input::{ - collect_prevouts, derive_xpriv_for_input_tap, derive_xpub_for_input_tap, Musig2Context, - Musig2Error, Musig2Input, Musig2PubNonce, +use super::{ + p2tr_musig2_input::{ + collect_prevouts, derive_xpriv_for_input_tap, derive_xpub_for_input_tap, Musig2Context, + Musig2Error, Musig2Input, Musig2PubNonce, + }, + BitGoPsbt, }; use crate::bitcoin::{ bip32::Xpriv, @@ -19,7 +22,6 @@ use crate::bitcoin::{ sighash::TapSighash, taproot::TapNodeHash, }; -use crate::bitgo_psbt::BitGoPsbt; use crate::fixed_script_wallet::RootWalletKeys; use musig2::{secp::Point, PubNonce}; diff --git a/packages/wasm-utxo/src/bitgo_psbt/propkv.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/propkv.rs similarity index 100% rename from packages/wasm-utxo/src/bitgo_psbt/propkv.rs rename to packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/propkv.rs diff --git a/packages/wasm-utxo/src/fixed_script_wallet/psbt_wallet_input.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs similarity index 74% rename from packages/wasm-utxo/src/fixed_script_wallet/psbt_wallet_input.rs rename to packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs index 4cd7fbdd..57b5a202 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/psbt_wallet_input.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs @@ -27,6 +27,20 @@ impl ReplayProtection { type Bip32DerivationMap = std::collections::BTreeMap; /// 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, _))| { + wallet_keys + .xpubs + .iter() + .any(|xpub| xpub.fingerprint() == *fingerprint) + }) +} + fn assert_bip32_derivation_map( wallet_keys: &RootWalletKeys, derivation_map: &Bip32DerivationMap, @@ -52,6 +66,20 @@ fn assert_bip32_derivation_map( 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, _)))| { + wallet_keys + .xpubs + .iter() + .any(|xpub| xpub.fingerprint() == *fingerprint) + }) +} + fn assert_tap_key_origins( wallet_keys: &RootWalletKeys, tap_key_origins: &TapKeyOrigins, @@ -111,7 +139,43 @@ fn parse_derivation_path(path: &DerivationPath) -> Result Result<(u32, u32), String> { +/// 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 + .bip32_derivation + .values() + .map(|(_, path)| path) + .collect() + } else { + input + .tap_key_origins + .values() + .map(|(_, (_, path))| path) + .collect() + } +} + +/// 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_derivation_path(key_origins: &[&DerivationPath]) -> Result<(u32, u32), String> { let paths = key_origins .iter() .map(|path| parse_derivation_path(path)) @@ -130,31 +194,15 @@ fn parse_shared_derivation_path(key_origins: &[&DerivationPath]) -> Result<(u32, Ok((chain, index)) } -fn parse_shared_chain_and_index(input: &Input) -> Result<(u32, u32), String> { +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(), ); } - if input.bip32_derivation.is_empty() { - return parse_shared_derivation_path( - &input - .tap_key_origins - .values() - .map(|(_, (_, path))| path) - .collect::>(), - ); - } - if input.tap_key_origins.is_empty() { - return parse_shared_derivation_path( - &input - .bip32_derivation - .values() - .map(|(_, path)| path) - .collect::>(), - ); - } - 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( @@ -198,7 +246,7 @@ pub fn assert_wallet_input( } #[derive(Debug)] -enum OutputScriptError { +pub enum OutputScriptError { OutputIndexOutOfBounds { vout: u32 }, BothUtxoFieldsSet, NoUtxoFields, @@ -220,22 +268,148 @@ impl std::fmt::Display for OutputScriptError { } } -fn get_output_script_from_input( +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, +} + +/// Parsed input from a PSBT transaction +#[derive(Debug, Clone)] +pub struct ParsedInput { + pub address: Option, + pub script: Vec, + pub value: u64, + pub script_id: 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 + pub fn parse( + psbt_input: &Input, + tx_input: &miniscript::bitcoin::TxIn, + wallet_keys: &RootWalletKeys, + 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 + } 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)?; + + Some(ScriptId { chain, index }) + }; + + // Convert script to address + let address = crate::address::networks::from_output_script_with_network( + output_script.as_script(), + network, + ) + .ok(); + + Ok(Self { + address, + script: output_script.to_bytes(), + value: value.to_sat(), + script_id, + }) + } +} + +/// Error type for parsing a single PSBT input +#[derive(Debug)] +pub enum ParseInputError { + /// Failed to extract output script or value from input + Utxo(OutputScriptError), + /// Input value overflow when adding to total + ValueOverflow, + /// Input missing or has invalid derivation info (and is not replay protection) + Derivation(String), + /// Input failed wallet validation + WalletValidation(String), + /// Failed to generate address for input + Address(crate::address::AddressError), +} + +impl std::fmt::Display for ParseInputError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ParseInputError::Utxo(error) => write!(f, "{}", error), + ParseInputError::ValueOverflow => write!(f, "value overflow"), + ParseInputError::Derivation(error) => { + write!( + f, + "missing or invalid derivation info (not replay protection): {}", + error + ) + } + ParseInputError::WalletValidation(error) => { + write!(f, "wallet validation failed: {}", error) + } + ParseInputError::Address(error) => { + write!(f, "failed to generate address: {}", error) + } + } + } +} + +impl std::error::Error for ParseInputError {} + +/// Get both output script and value from a PSBT input +pub fn get_output_script_and_value( input: &Input, prevout: OutPoint, -) -> Result<&ScriptBuf, OutputScriptError> { +) -> Result<(&ScriptBuf, miniscript::bitcoin::Amount), OutputScriptError> { match (&input.witness_utxo, &input.non_witness_utxo) { - (Some(witness_utxo), None) => Ok(&witness_utxo.script_pubkey), - (None, Some(non_witness_utxo)) => non_witness_utxo - .output - .get(prevout.vout as usize) - .map(|output| &output.script_pubkey) - .ok_or(OutputScriptError::OutputIndexOutOfBounds { vout: prevout.vout }), + (Some(witness_utxo), None) => Ok((&witness_utxo.script_pubkey, witness_utxo.value)), + (None, Some(non_witness_utxo)) => { + let output = non_witness_utxo + .output + .get(prevout.vout as usize) + .ok_or(OutputScriptError::OutputIndexOutOfBounds { vout: prevout.vout })?; + Ok((&output.script_pubkey, output.value)) + } (Some(_), Some(_)) => Err(OutputScriptError::BothUtxoFieldsSet), (None, None) => Err(OutputScriptError::NoUtxoFields), } } +fn get_output_script_from_input( + input: &Input, + prevout: OutPoint, +) -> Result<&ScriptBuf, OutputScriptError> { + // Delegate to get_output_script_and_value and return just the script + get_output_script_and_value(input, prevout).map(|(script, _value)| script) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum InputValidationErrorKind { /// Failed to extract output script from input @@ -376,6 +550,8 @@ pub fn validate_psbt_wallet_inputs( #[cfg(test)] pub mod test_helpers { use super::*; + use crate::fixed_script_wallet::{RootWalletKeys, XpubTriple}; + use crate::test_utils::fixtures; /// Checks if a specific input in a PSBT is protected by replay protection pub fn is_replay_protected_input( @@ -545,13 +721,6 @@ pub mod test_helpers { } } } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::fixed_script_wallet::{RootWalletKeys, XpubTriple}; - use crate::test_utils::fixtures; fn get_reversed_wallet_keys(wallet_keys: &RootWalletKeys) -> RootWalletKeys { let triple: XpubTriple = wallet_keys @@ -565,8 +734,6 @@ mod tests { } crate::test_psbt_fixtures!(test_validate_psbt_wallet_inputs, network, format, { - use crate::fixed_script_wallet::psbt_wallet_input::test_helpers::*; - let replay_protection = ReplayProtection::new(vec![ ScriptBuf::from_hex("a91420b37094d82a513451ff0ccd9db23aba05bc5ef387") .expect("Failed to parse replay protection output script"), 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 new file mode 100644 index 00000000..20b33ebb --- /dev/null +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_output.rs @@ -0,0 +1,145 @@ +use miniscript::bitcoin::psbt::Output; +use miniscript::bitcoin::ScriptBuf; + +use crate::fixed_script_wallet::{Chain, RootWalletKeys, WalletScripts}; +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 { + pub address: Option, + pub script: Vec, + pub value: u64, + pub script_id: 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 + /// + /// # 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, + wallet_keys: &RootWalletKeys, + network: Network, + ) -> 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)?; + + // 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(); + + Ok(Self { + address, + script: script.to_bytes(), + value: tx_output.value.to_sat(), + script_id, + }) + } + + /// Returns true if this is an external output (not belonging to the wallet) + pub fn is_external(&self) -> bool { + self.script_id.is_none() + } +} + +/// Error type for parsing a single PSBT output +#[derive(Debug)] +pub enum ParseOutputError { + /// Failed to match output to wallet (corruption or validation error) + WalletMatch(String), +} + +impl std::fmt::Display for ParseOutputError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ParseOutputError::WalletMatch(error) => write!(f, "{}", error), + } + } +} + +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/bitgo_psbt/sighash.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/sighash.rs similarity index 100% rename from packages/wasm-utxo/src/bitgo_psbt/sighash.rs rename to packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/sighash.rs diff --git a/packages/wasm-utxo/src/bitgo_psbt/zcash_psbt.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs similarity index 100% rename from packages/wasm-utxo/src/bitgo_psbt/zcash_psbt.rs rename to packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs diff --git a/packages/wasm-utxo/src/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/mod.rs index 85996092..af9bb177 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/mod.rs @@ -1,6 +1,6 @@ /// This module contains code for the BitGo Fixed Script Wallets. /// These are not based on descriptors. -pub mod psbt_wallet_input; +pub mod bitgo_psbt; mod wallet_keys; pub mod wallet_scripts; diff --git a/packages/wasm-utxo/src/fixed_script_wallet/test_utils/fixtures.rs b/packages/wasm-utxo/src/fixed_script_wallet/test_utils/fixtures.rs index f1865ebd..ee649d85 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/test_utils/fixtures.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/test_utils/fixtures.rs @@ -41,13 +41,16 @@ use std::str::FromStr; use crate::{ - bitcoin::bip32::Xpriv, bitgo_psbt::p2tr_musig2_input, fixed_script_wallet::RootWalletKeys, + bitcoin::bip32::Xpriv, + fixed_script_wallet::{ + bitgo_psbt::{p2tr_musig2_input, validate_sighash_type, BitGoPsbt}, + RootWalletKeys, + }, + Network, }; use miniscript::bitcoin::bip32::Xpub; use serde::{Deserialize, Serialize}; -use crate::Network; - #[derive(Debug, Clone, PartialEq, Eq)] pub struct XprvTriple([Xpriv; 3]); @@ -205,7 +208,7 @@ impl Musig2Participants { } for (i, parsed_key) in parsed.participant_pub_keys.iter().enumerate() { - let parsed_key_hex = hex::encode(parsed_key.to_bytes()); + let parsed_key_hex = hex::encode(parsed_key.to_bytes().as_slice()); assert_hex_eq( &parsed_key_hex, &self.participant_pub_keys[i], @@ -659,11 +662,8 @@ impl PsbtFixture { Ok(BASE64_STANDARD.decode(&self.psbt_base64)?) } - pub fn to_bitgo_psbt( - &self, - network: Network, - ) -> Result> { - let psbt = crate::bitgo_psbt::BitGoPsbt::deserialize(&self.to_psbt_bytes()?, network)?; + pub fn to_bitgo_psbt(&self, network: Network) -> Result> { + let psbt = BitGoPsbt::deserialize(&self.to_psbt_bytes()?, network)?; Ok(psbt) } @@ -880,8 +880,8 @@ pub fn assert_hex_eq(generated: &str, expected: &str, field_name: &str) -> Resul } /// Validates sighash type for the given network -fn validate_sighash_type(sighash_type: u32, network: Network) -> Result<(), String> { - crate::bitgo_psbt::validate_sighash_type(sighash_type, network) +fn validate_sighash_type_fixture(sighash_type: u32, network: Network) -> Result<(), String> { + validate_sighash_type(sighash_type, network) } /// Validates output script from witness UTXO against generated script @@ -926,7 +926,7 @@ impl P2shInput { let redeem_script_hex = scripts.redeem_script.to_hex_string(); assert_hex_eq(&redeem_script_hex, &self.redeem_script, "Redeem script")?; - validate_sighash_type(self.sighash_type, network) + validate_sighash_type_fixture(self.sighash_type, network) } } @@ -950,7 +950,7 @@ impl P2shP2wshInput { let witness_script_hex = scripts.witness_script.to_hex_string(); assert_hex_eq(&witness_script_hex, &self.witness_script, "Witness script")?; - validate_sighash_type(self.sighash_type, network) + validate_sighash_type_fixture(self.sighash_type, network) } } @@ -970,7 +970,7 @@ impl P2wshInput { let witness_script_hex = scripts.witness_script.to_hex_string(); assert_hex_eq(&witness_script_hex, &self.witness_script, "Witness script")?; - validate_sighash_type(self.sighash_type, network) + validate_sighash_type_fixture(self.sighash_type, network) } } @@ -1017,7 +1017,7 @@ impl P2trScriptPathInput { } } - validate_sighash_type(self.sighash_type, network) + validate_sighash_type_fixture(self.sighash_type, network) } /// Validates that the generated WalletScripts matches this fixture @@ -1052,7 +1052,7 @@ impl P2trMusig2KeyPathInput { let merkle_root_hex = hex::encode(merkle_root_bytes); assert_hex_eq(&merkle_root_hex, &self.tap_merkle_root, "Merkle root")?; - validate_sighash_type(self.sighash_type, network) + validate_sighash_type_fixture(self.sighash_type, network) } /// Validates that the generated WalletScripts matches this fixture diff --git a/packages/wasm-utxo/src/lib.rs b/packages/wasm-utxo/src/lib.rs index 07e7dbe4..817b9e6e 100644 --- a/packages/wasm-utxo/src/lib.rs +++ b/packages/wasm-utxo/src/lib.rs @@ -1,5 +1,4 @@ mod address; -pub mod bitgo_psbt; mod error; pub mod fixed_script_wallet; mod networks; diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs index 2931e314..3e2aaffc 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs @@ -2,12 +2,77 @@ use wasm_bindgen::prelude::*; use wasm_bindgen::JsValue; use crate::address::networks::AddressFormat; -use crate::address::utxolib_compat::UtxolibNetwork; use crate::error::WasmUtxoError; use crate::fixed_script_wallet::{Chain, WalletScripts}; +use crate::utxolib_compat::UtxolibNetwork; use crate::wasm::try_from_js_value::TryFromJsValue; +use crate::wasm::try_from_js_value::{get_buffer_array_field, get_string_array_field}; +use crate::wasm::try_into_js_value::TryIntoJsValue; use crate::wasm::wallet_keys_helpers::root_wallet_keys_from_jsvalue; +/// Parse a network from a string that can be either a utxolib name or a coin name +fn parse_network(network_str: &str) -> Result { + crate::networks::Network::from_utxolib_name(network_str) + .or_else(|| crate::networks::Network::from_coin_name(network_str)) + .ok_or_else(|| { + WasmUtxoError::new(&format!( + "Unknown network '{}'. Expected a utxolib name (e.g., 'bitcoin', 'testnet') or coin name (e.g., 'btc', 'tbtc')", + network_str + )) + }) +} + +/// Helper function to create ReplayProtection from JsValue +/// Supports two formats: +/// 1. { outputScripts: Buffer[] } - direct scripts +/// 2. { addresses: string[] } - addresses to decode (uses provided network) +fn replay_protection_from_js_value( + replay_protection: &JsValue, + network: crate::networks::Network, +) -> Result< + crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::ReplayProtection, + WasmUtxoError, +> { + // Try to get outputScripts first + if let Ok(script_bytes) = get_buffer_array_field(replay_protection, "outputScripts") { + let permitted_scripts = script_bytes + .into_iter() + .map(miniscript::bitcoin::ScriptBuf::from_bytes) + .collect(); + + return Ok( + crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::ReplayProtection::new( + permitted_scripts, + ), + ); + } + + // Try to get addresses array + let addresses = get_string_array_field(replay_protection, "addresses").map_err(|_| { + WasmUtxoError::new("replay_protection must have either outputScripts or addresses property") + })?; + + // Convert addresses to scripts using provided network + let mut permitted_scripts = Vec::new(); + for address_str in addresses { + let script = crate::address::networks::to_output_script_with_network(&address_str, network) + .map_err(|e| { + WasmUtxoError::new(&format!( + "Failed to decode address '{}': {}", + address_str, e + )) + })?; + + permitted_scripts.push(script); + } + + Ok( + crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::ReplayProtection::new( + permitted_scripts, + ), + ) +} + #[wasm_bindgen] pub struct FixedScriptWalletNamespace; @@ -61,6 +126,49 @@ impl FixedScriptWalletNamespace { address_format, ) .map_err(|e| WasmUtxoError::new(&format!("Failed to generate address: {}", e)))?; - Ok(address.to_string()) + Ok(address) + } +} +#[wasm_bindgen] +pub struct BitGoPsbt { + psbt: crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt, +} + +#[wasm_bindgen] +impl BitGoPsbt { + /// Deserialize a PSBT from bytes with network-specific logic + #[wasm_bindgen(js_name = fromBytes)] + pub fn from_bytes(bytes: &[u8], network: &str) -> Result { + let network = parse_network(network)?; + + let psbt = + crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::deserialize(bytes, network) + .map_err(|e| WasmUtxoError::new(&format!("Failed to deserialize PSBT: {}", e)))?; + + Ok(BitGoPsbt { psbt }) + } + + /// Parse transaction with wallet keys to identify wallet inputs/outputs + #[wasm_bindgen(js_name = parseTransactionWithWalletKeys)] + pub fn parse_transaction_with_wallet_keys( + &self, + wallet_keys: JsValue, + replay_protection: JsValue, + ) -> Result { + // Convert wallet keys from JsValue + let wallet_keys = root_wallet_keys_from_jsvalue(&wallet_keys)?; + + // Convert replay protection from JsValue, using the PSBT's network + let network = self.psbt.network(); + let replay_protection = replay_protection_from_js_value(&replay_protection, network)?; + + // Call the Rust implementation + let parsed_tx = self + .psbt + .parse_transaction_with_wallet_keys(&wallet_keys, &replay_protection) + .map_err(|e| WasmUtxoError::new(&format!("Failed to parse transaction: {}", e)))?; + + // Convert to JsValue directly using TryIntoJsValue + parsed_tx.try_to_js_value() } } 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 be53cb91..73ef0c6a 100644 --- a/packages/wasm-utxo/src/wasm/try_from_js_value.rs +++ b/packages/wasm-utxo/src/wasm/try_from_js_value.rs @@ -55,6 +55,77 @@ pub(crate) fn get_field(obj: &JsValue, key: &str) -> Result( + obj: &JsValue, + key: &str, +) -> Result, WasmUtxoError> { + let field_value = js_sys::Reflect::get(obj, &JsValue::from_str(key)) + .map_err(|_| WasmUtxoError::new(&format!("Failed to read {} from object", key)))?; + + if field_value.is_undefined() || field_value.is_null() { + Ok(None) + } else { + T::try_from_js_value(&field_value) + .map(Some) + .map_err(|e| WasmUtxoError::new(&format!("{} (field: {})", e, key))) + } +} + +// Helper function to get an array field +pub(crate) fn get_array_field(obj: &JsValue, key: &str) -> Result { + use wasm_bindgen::JsCast; + + let field_value = js_sys::Reflect::get(obj, &JsValue::from_str(key)) + .map_err(|_| WasmUtxoError::new(&format!("Failed to read {} from object", key)))?; + + field_value + .dyn_into::() + .map_err(|_| WasmUtxoError::new(&format!("{} must be an array", key))) +} + +// Helper function to get a string array field +pub(crate) fn get_string_array_field( + obj: &JsValue, + key: &str, +) -> Result, WasmUtxoError> { + let array = get_array_field(obj, key)?; + let mut result = Vec::new(); + + for i in 0..array.length() { + let item = array.get(i); + let string = item + .as_string() + .ok_or_else(|| WasmUtxoError::new(&format!("{} items must be strings", key)))?; + result.push(string); + } + + Ok(result) +} + +// Helper function to get a buffer array field (array of Uint8Array/Buffer) +pub(crate) fn get_buffer_array_field( + obj: &JsValue, + key: &str, +) -> Result>, WasmUtxoError> { + use wasm_bindgen::JsCast; + + let array = get_array_field(obj, key)?; + let mut result = Vec::new(); + + for i in 0..array.length() { + let item = array.get(i); + let buffer = item + .dyn_into::() + .map_err(|_| WasmUtxoError::new(&format!("{} items must be Uint8Array/Buffer", key)))?; + + result.push(buffer.to_vec()); + } + + Ok(result) +} + // Helper function to get a nested field using dot notation (e.g., "network.bip32.public") pub(crate) fn get_nested_field( obj: &JsValue, 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 fe54f142..5b67c6b1 100644 --- a/packages/wasm-utxo/src/wasm/try_into_js_value.rs +++ b/packages/wasm-utxo/src/wasm/try_into_js_value.rs @@ -128,6 +128,24 @@ impl TryIntoJsValue for usize { } } +impl TryIntoJsValue for u32 { + fn try_to_js_value(&self) -> Result { + Ok(JsValue::from_f64(*self as f64)) + } +} + +impl TryIntoJsValue for u64 { + fn try_to_js_value(&self) -> Result { + Ok(js_sys::BigInt::from(*self).into()) + } +} + +impl TryIntoJsValue for Vec { + fn try_to_js_value(&self) -> Result { + Ok(js_sys::Uint8Array::from(self.as_slice()).into()) + } +} + impl TryIntoJsValue for Threshold { fn try_to_js_value(&self) -> Result { let arr = Array::new(); @@ -289,3 +307,45 @@ impl TryIntoJsValue for SigningKeysMap { Ok(obj.into()) } } + +impl TryIntoJsValue for crate::fixed_script_wallet::bitgo_psbt::ScriptId { + fn try_to_js_value(&self) -> Result { + js_obj!( + "chain" => self.chain, + "index" => self.index + ) + } +} + +impl TryIntoJsValue for crate::fixed_script_wallet::bitgo_psbt::ParsedInput { + fn try_to_js_value(&self) -> Result { + js_obj!( + "address" => self.address.clone(), + "value" => self.value, + "scriptId" => self.script_id + ) + } +} + +impl TryIntoJsValue for crate::fixed_script_wallet::bitgo_psbt::ParsedOutput { + fn try_to_js_value(&self) -> Result { + js_obj!( + "address" => self.address.clone(), + "script" => self.script.clone(), + "value" => self.value, + "scriptId" => self.script_id + ) + } +} + +impl TryIntoJsValue for crate::fixed_script_wallet::bitgo_psbt::ParsedTransaction { + fn try_to_js_value(&self) -> Result { + js_obj!( + "inputs" => self.inputs.clone(), + "outputs" => self.outputs.clone(), + "spendAmount" => self.spend_amount, + "minerFee" => self.miner_fee, + "virtualSize" => self.virtual_size + ) + } +} diff --git a/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts b/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts new file mode 100644 index 00000000..a377d12e --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts @@ -0,0 +1,156 @@ +import assert from "node:assert"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as utxolib from "@bitgo/utxo-lib"; +import { fixedScriptWallet } from "../../js"; + +type Triple = [T, T, T]; + +/** + * Load a PSBT fixture from JSON file and return the PSBT bytes + */ +function loadPsbtFixture(network: string): Buffer { + const fixturePath = path.join( + __dirname, + "..", + "fixtures", + "fixed-script", + `psbt-lite.${network}.fullsigned.json`, + ); + const fixtureContent = fs.readFileSync(fixturePath, "utf-8"); + const fixture = JSON.parse(fixtureContent) as { psbtBase64: string; walletKeys: string[] }; + return Buffer.from(fixture.psbtBase64, "base64"); +} + +/** + * Load wallet keys from fixture + */ +function loadWalletKeysFromFixture(network: string): utxolib.bitgo.RootWalletKeys { + const fixturePath = path.join( + __dirname, + "..", + "fixtures", + "fixed-script", + `psbt-lite.${network}.fullsigned.json`, + ); + const fixtureContent = fs.readFileSync(fixturePath, "utf-8"); + const fixture = JSON.parse(fixtureContent) as { walletKeys: string[] }; + + // Parse xprvs and convert to xpubs + const xpubs = fixture.walletKeys.map((xprv) => { + const key = utxolib.bip32.fromBase58(xprv); + return key.neutered(); + }); + + return new utxolib.bitgo.RootWalletKeys(xpubs as Triple); +} + +describe("parseTransactionWithWalletKeys", function () { + // Replay protection script that matches Rust tests + const replayProtectionScript = Buffer.from( + "a91420b37094d82a513451ff0ccd9db23aba05bc5ef387", + "hex", + ); + + const supportedNetworks = utxolib.getNetworkList().filter((network) => { + return ( + utxolib.isMainnet(network) && + network !== utxolib.networks.bitcoincash && + network !== utxolib.networks.bitcoingold && + network !== utxolib.networks.bitcoinsv && + network !== utxolib.networks.ecash && + network !== utxolib.networks.zcash + ); + }); + + function hasReplayProtection(network: utxolib.Network): boolean { + const mainnet = utxolib.getMainnet(network); + return mainnet === utxolib.networks.bitcoincash; + } + + supportedNetworks.forEach((network) => { + const networkName = utxolib.getNetworkName(network); + + describe(`network: ${networkName}`, function () { + it("should parse transaction and identify internal/external outputs", function () { + // Load PSBT from fixture + const psbtBytes = loadPsbtFixture(networkName); + const rootWalletKeys = loadWalletKeysFromFixture(networkName); + + // Parse with WASM + const bitgoPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(psbtBytes, networkName); + const parsed = bitgoPsbt.parseTransactionWithWalletKeys(rootWalletKeys, { + outputScripts: [replayProtectionScript], + }); + + // Verify all inputs have addresses and values + parsed.inputs.forEach((input, i) => { + assert.ok(input.address, `Input ${i} should have an address`); + assert.ok(typeof input.value === "bigint", `Input ${i} value should be bigint`); + assert.ok(input.value > 0n, `Input ${i} value should be > 0`); + }); + + // Validate outputs + assert.ok(parsed.outputs.length > 0, "Should have at least one output"); + + // Count internal outputs (scriptId is defined) + const internalOutputs = parsed.outputs.filter((o) => o.scriptId !== undefined); + + // Count external outputs (scriptId is undefined) + const externalOutputs = parsed.outputs.filter((o) => o.scriptId === undefined); + + // All outputs in the fixture are internal + assert.ok(internalOutputs.length > 0, "All outputs should be internal (have scriptId)"); + assert.strictEqual( + externalOutputs.length, + 0, + "Should have no external outputs in test fixture", + ); + + // Verify all outputs have proper structure + parsed.outputs.forEach((output, i) => { + assert.ok(output.script instanceof Uint8Array, `Output ${i} script should be Uint8Array`); + assert.ok(typeof output.value === "bigint", `Output ${i} value should be bigint`); + assert.ok(output.value > 0n, `Output ${i} value should be > 0`); + // Address is optional for non-standard scripts + }); + + // Verify spend amount (should be 0 since all outputs are internal) + assert.strictEqual( + parsed.spendAmount, + 0n, + "Spend amount should be 0 when all outputs are internal", + ); + + // Verify miner fee calculation + const totalInputValue = parsed.inputs.reduce((sum, i) => sum + i.value, 0n); + const totalOutputValue = parsed.outputs.reduce((sum, o) => sum + o.value, 0n); + assert.strictEqual( + parsed.minerFee, + totalInputValue - totalOutputValue, + "Miner fee should equal inputs minus outputs", + ); + assert.ok(parsed.minerFee > 0n, "Miner fee should be > 0"); + + // Verify virtual size + assert.ok(typeof parsed.virtualSize === "number", "Virtual size should be a number"); + assert.ok(parsed.virtualSize > 0, "Virtual size should be > 0"); + }); + }); + }); + + describe("error handling", function () { + it("should throw error for invalid PSBT bytes", function () { + const invalidBytes = new Uint8Array([0x00, 0x01, 0x02]); + assert.throws( + () => { + fixedScriptWallet.BitGoPsbt.fromBytes(invalidBytes, "bitcoin"); + }, + (error: Error) => { + return error.message.includes("Failed to deserialize PSBT"); + }, + "Should throw error for invalid PSBT bytes", + ); + }); + }); +});