From 5eaa41b5b60629cc073b303b89364cb70ae1b561 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Fri, 7 Nov 2025 15:22:12 +0100 Subject: [PATCH 1/2] refactor(wasm-utxo): move bitgo_psbt to fixed_script_wallet module Move BitGo PSBT functionality from root module to fixed_script_wallet where it logically belongs. Update imports and paths across files to maintain functionality. Also move psbt_wallet_input.rs into the bitgo_psbt module. Issue: BTC-2652 Co-authored-by: llm-git --- packages/wasm-utxo/cli/src/parse/node.rs | 4 +-- .../bitgo_psbt/mod.rs | 5 +-- .../bitgo_psbt/p2tr_musig2_input.rs | 21 ++++++------ .../bitgo_psbt/p2tr_musig2_input_utxolib.rs | 10 +++--- .../bitgo_psbt/propkv.rs | 0 .../{ => bitgo_psbt}/psbt_wallet_input.rs | 11 ++----- .../bitgo_psbt/sighash.rs | 0 .../bitgo_psbt/zcash_psbt.rs | 0 .../wasm-utxo/src/fixed_script_wallet/mod.rs | 2 +- .../test_utils/fixtures.rs | 32 +++++++++---------- packages/wasm-utxo/src/lib.rs | 1 - 11 files changed, 42 insertions(+), 44 deletions(-) rename packages/wasm-utxo/src/{ => fixed_script_wallet}/bitgo_psbt/mod.rs (99%) rename packages/wasm-utxo/src/{ => fixed_script_wallet}/bitgo_psbt/p2tr_musig2_input.rs (99%) rename packages/wasm-utxo/src/{ => fixed_script_wallet}/bitgo_psbt/p2tr_musig2_input_utxolib.rs (99%) rename packages/wasm-utxo/src/{ => fixed_script_wallet}/bitgo_psbt/propkv.rs (100%) rename packages/wasm-utxo/src/fixed_script_wallet/{ => bitgo_psbt}/psbt_wallet_input.rs (99%) rename packages/wasm-utxo/src/{ => fixed_script_wallet}/bitgo_psbt/sighash.rs (100%) rename packages/wasm-utxo/src/{ => fixed_script_wallet}/bitgo_psbt/zcash_psbt.rs (100%) 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/src/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs similarity index 99% 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..b63ca677 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,15 @@ pub mod p2tr_musig2_input; #[cfg(test)] mod p2tr_musig2_input_utxolib; mod propkv; +mod psbt_wallet_input; 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 { 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 99% 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..6c1c6695 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 @@ -376,6 +376,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 +547,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 +560,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/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; From 7d39f45ebcb013370df9b470ad1eb7095a2d8a37 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Fri, 7 Nov 2025 17:46:50 +0100 Subject: [PATCH 2/2] feat(wasm-utxo): add BitGoPsbt class for transaction parsing Implements a new BitGoPsbt class that allows parsing PSBT transactions to identify wallet inputs and outputs. This implementation provides: - PSBT deserialization with network-specific handling - Transaction parsing with wallet script identification - Input validation with replay protection support - Output classification (internal vs external) - Fee and spend amount calculations The implementation extracts derivation info from PSBT inputs/outputs to match against wallet keys, providing a structured view of transactions for wallet UIs and validation purposes. Added comprehensive tests for the new functionality across all supported networks. Includes TypeScript definitions and integration with existing fixed script wallet functionality. Issue: BTC-2652 Co-authored-by: llm-git --- packages/wasm-utxo/js/fixedScriptWallet.ts | 67 +++- packages/wasm-utxo/js/utxolibCompat.ts | 23 ++ .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 286 +++++++++++++++++- .../bitgo_psbt/psbt_wallet_input.rs | 234 ++++++++++++-- .../bitgo_psbt/psbt_wallet_output.rs | 145 +++++++++ .../wasm-utxo/src/wasm/fixed_script_wallet.rs | 112 ++++++- .../wasm-utxo/src/wasm/try_from_js_value.rs | 71 +++++ .../wasm-utxo/src/wasm/try_into_js_value.rs | 60 ++++ .../parseTransactionWithWalletKeys.ts | 156 ++++++++++ 9 files changed, 1120 insertions(+), 34 deletions(-) create mode 100644 packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_output.rs create mode 100644 packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts 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/fixed_script_wallet/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs index b63ca677..6a59d6bd 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 @@ -7,7 +7,8 @@ pub mod p2tr_musig2_input; #[cfg(test)] mod p2tr_musig2_input_utxolib; mod propkv; -mod psbt_wallet_input; +pub mod psbt_wallet_input; +pub mod psbt_wallet_output; mod sighash; mod zcash_psbt; @@ -95,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 { @@ -397,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)] @@ -884,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/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_input.rs index 6c1c6695..57b5a202 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 @@ -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 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/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", + ); + }); + }); +});