diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index 0ceccc5..6942f55 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -31,6 +31,7 @@ export type ParsedInput = { value: bigint; scriptId: ScriptId | null; scriptType: InputScriptType; + sequence: number; }; export type ParsedOutput = { @@ -49,9 +50,98 @@ export type ParsedTransaction = { virtualSize: number; }; +export type CreateEmptyOptions = { + /** Transaction version (default: 2) */ + version?: number; + /** Lock time (default: 0) */ + lockTime?: number; +}; + +export type AddInputOptions = { + /** Previous transaction ID (hex string) */ + txid: string; + /** Output index being spent */ + vout: number; + /** Value in satoshis (for witness_utxo) */ + value: bigint; + /** Sequence number (default: 0xFFFFFFFE for RBF) */ + sequence?: number; + /** Full previous transaction (for non-segwit strict compliance) */ + prevTx?: Uint8Array; +}; + +export type AddOutputOptions = { + /** Output script (scriptPubKey) */ + script: Uint8Array; + /** Value in satoshis */ + value: bigint; +}; + +/** Key identifier for signing ("user", "backup", or "bitgo") */ +export type SignerKey = "user" | "backup" | "bitgo"; + +/** Specifies signer and cosigner for Taproot inputs */ +export type SignPath = { + /** Key that will sign */ + signer: SignerKey; + /** Key that will co-sign */ + cosigner: SignerKey; +}; + +export type AddWalletInputOptions = { + /** Script location in wallet (chain + index) */ + scriptId: ScriptId; + /** Sign path - required for p2tr/p2trMusig2 (chains 30-41) */ + signPath?: SignPath; +}; + +export type AddWalletOutputOptions = { + /** Chain code (0/1=p2sh, 10/11=p2shP2wsh, 20/21=p2wsh, 30/31=p2tr, 40/41=p2trMusig2) */ + chain: number; + /** Derivation index */ + index: number; + /** Value in satoshis */ + value: bigint; +}; + export class BitGoPsbt { private constructor(private wasm: WasmBitGoPsbt) {} + /** + * Create an empty PSBT for the given network with wallet keys + * + * The wallet keys are used to set global xpubs in the PSBT, which identifies + * the keys that will be used for signing. + * + * @param network - Network name (utxolib name like "bitcoin" or coin name like "btc") + * @param walletKeys - The wallet's root keys (sets global xpubs in the PSBT) + * @param options - Optional transaction parameters (version, lockTime) + * @returns A new empty BitGoPsbt instance + * + * @example + * ```typescript + * // Create empty PSBT with wallet keys + * const psbt = BitGoPsbt.createEmpty("bitcoin", walletKeys); + * + * // Create with custom version and lockTime + * const psbt = BitGoPsbt.createEmpty("bitcoin", walletKeys, { version: 1, lockTime: 500000 }); + * ``` + */ + static createEmpty( + network: NetworkName, + walletKeys: WalletKeysArg, + options?: CreateEmptyOptions, + ): BitGoPsbt { + const keys = RootWalletKeys.from(walletKeys); + const wasm = WasmBitGoPsbt.create_empty( + network, + keys.wasm, + options?.version, + options?.lockTime, + ); + return new BitGoPsbt(wasm); + } + /** * Deserialize a PSBT from bytes * @param bytes - The PSBT bytes @@ -63,6 +153,178 @@ export class BitGoPsbt { return new BitGoPsbt(wasm); } + /** + * Add an input to the PSBT + * + * This adds a transaction input and corresponding PSBT input metadata. + * The witness_utxo is automatically populated for modern signing compatibility. + * + * @param options - Input options (txid, vout, value, sequence) + * @param script - Output script of the UTXO being spent + * @returns The index of the newly added input + * + * @example + * ```typescript + * const inputIndex = psbt.addInput({ + * txid: "abc123...", + * vout: 0, + * value: 100000n, + * }, outputScript); + * ``` + */ + addInput(options: AddInputOptions, script: Uint8Array): number { + return this.wasm.add_input( + options.txid, + options.vout, + options.value, + script, + options.sequence, + options.prevTx, + ); + } + + /** + * Add an output to the PSBT + * + * @param options - Output options (script, value) + * @returns The index of the newly added output + * + * @example + * ```typescript + * const outputIndex = psbt.addOutput({ + * script: outputScript, + * value: 50000n, + * }); + * ``` + */ + addOutput(options: AddOutputOptions): number { + return this.wasm.add_output(options.script, options.value); + } + + /** + * Add a wallet input with full PSBT metadata + * + * This is a higher-level method that adds an input and populates all required + * PSBT fields (scripts, derivation info, etc.) based on the wallet's chain type. + * + * For p2sh/p2shP2wsh/p2wsh: Sets bip32Derivation, witnessScript, redeemScript (signPath not needed) + * For p2tr/p2trMusig2 script path: Sets tapLeafScript, tapBip32Derivation (signPath required) + * For p2trMusig2 key path: Sets tapInternalKey, tapMerkleRoot, tapBip32Derivation, musig2 participants (signPath required) + * + * @param inputOptions - Common input options (txid, vout, value, sequence) + * @param walletKeys - The wallet's root keys + * @param walletOptions - Wallet-specific options (scriptId, signPath, prevTx) + * @returns The index of the newly added input + * + * @example + * ```typescript + * // Add a p2shP2wsh input (signPath not needed) + * const inputIndex = psbt.addWalletInput( + * { txid: "abc123...", vout: 0, value: 100000n }, + * walletKeys, + * { scriptId: { chain: 10, index: 0 } }, // p2shP2wsh external + * ); + * + * // Add a p2trMusig2 key path input (signPath required) + * const inputIndex = psbt.addWalletInput( + * { txid: "def456...", vout: 1, value: 50000n }, + * walletKeys, + * { scriptId: { chain: 40, index: 5 }, signPath: { signer: "user", cosigner: "bitgo" } }, + * ); + * + * // Add p2trMusig2 with backup key (script path spend) + * const inputIndex = psbt.addWalletInput( + * { txid: "ghi789...", vout: 0, value: 75000n }, + * walletKeys, + * { scriptId: { chain: 40, index: 3 }, signPath: { signer: "user", cosigner: "backup" } }, + * ); + * ``` + */ + addWalletInput( + inputOptions: AddInputOptions, + walletKeys: WalletKeysArg, + walletOptions: AddWalletInputOptions, + ): number { + const keys = RootWalletKeys.from(walletKeys); + return this.wasm.add_wallet_input( + inputOptions.txid, + inputOptions.vout, + inputOptions.value, + keys.wasm, + walletOptions.scriptId.chain, + walletOptions.scriptId.index, + walletOptions.signPath?.signer, + walletOptions.signPath?.cosigner, + inputOptions.sequence, + inputOptions.prevTx, + ); + } + + /** + * Add a wallet output with full PSBT metadata + * + * This creates a verifiable wallet output (typically for change) with all required + * PSBT fields (scripts, derivation info) based on the wallet's chain type. + * + * For p2sh/p2shP2wsh/p2wsh: Sets bip32Derivation, witnessScript, redeemScript + * For p2tr/p2trMusig2: Sets tapInternalKey, tapBip32Derivation + * + * @param walletKeys - The wallet's root keys + * @param options - Output options including chain, index, and value + * @returns The index of the newly added output + * + * @example + * ```typescript + * // Add a p2shP2wsh change output + * const outputIndex = psbt.addWalletOutput(walletKeys, { + * chain: 11, // p2shP2wsh internal (change) + * index: 0, + * value: 50000n, + * }); + * + * // Add a p2trMusig2 change output + * const outputIndex = psbt.addWalletOutput(walletKeys, { + * chain: 41, // p2trMusig2 internal (change) + * index: 5, + * value: 25000n, + * }); + * ``` + */ + addWalletOutput(walletKeys: WalletKeysArg, options: AddWalletOutputOptions): number { + const keys = RootWalletKeys.from(walletKeys); + return this.wasm.add_wallet_output(options.chain, options.index, options.value, keys.wasm); + } + + /** + * Add a replay protection input to the PSBT + * + * Replay protection inputs are P2SH-P2PK inputs used on forked networks to prevent + * transaction replay attacks. They use a simple pubkey script without wallet derivation. + * + * @param inputOptions - Common input options (txid, vout, value, sequence) + * @param key - ECPair containing the public key for the replay protection input + * @returns The index of the newly added input + * + * @example + * ```typescript + * // Add a replay protection input using ECPair + * const inputIndex = psbt.addReplayProtectionInput( + * { txid: "abc123...", vout: 0, value: 1000n }, + * replayProtectionKey, + * ); + * ``` + */ + addReplayProtectionInput(inputOptions: AddInputOptions, key: ECPairArg): number { + const ecpair = ECPair.from(key); + return this.wasm.add_replay_protection_input( + ecpair.wasm, + inputOptions.txid, + inputOptions.vout, + inputOptions.value, + inputOptions.sequence, + ); + } + /** * Get the unsigned transaction ID * @returns The unsigned transaction ID @@ -71,6 +333,22 @@ export class BitGoPsbt { return this.wasm.unsigned_txid(); } + /** + * Get the transaction version + * @returns The transaction version number + */ + get version(): number { + return this.wasm.version(); + } + + /** + * Get the transaction lock time + * @returns The transaction lock time + */ + get lockTime(): number { + return this.wasm.lock_time(); + } + /** * Parse transaction with wallet keys to identify wallet inputs/outputs * @param walletKeys - The wallet keys to use for identification diff --git a/packages/wasm-utxo/js/fixedScriptWallet/index.ts b/packages/wasm-utxo/js/fixedScriptWallet/index.ts index bd3263f..cde6327 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/index.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/index.ts @@ -9,4 +9,5 @@ export { type ParsedInput, type ParsedOutput, type ParsedTransaction, + type SignPath, } from "./BitGoPsbt.js"; 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 e2baf68..ca752db 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 @@ -97,7 +97,9 @@ pub enum BitGoPsbt { } // Re-export types from submodules for convenience -pub use psbt_wallet_input::{InputScriptType, ParsedInput, ScriptId}; +pub use psbt_wallet_input::{ + InputScriptType, ParsedInput, ReplayProtectionOptions, ScriptId, WalletInputOptions, +}; pub use psbt_wallet_output::ParsedOutput; /// Parsed transaction with wallet information @@ -160,6 +162,113 @@ impl std::fmt::Display for ParseTransactionError { impl std::error::Error for ParseTransactionError {} +/// Get the default sighash type for a network and chain type +fn get_default_sighash_type( + network: Network, + chain: crate::fixed_script_wallet::wallet_scripts::Chain, +) -> miniscript::bitcoin::psbt::PsbtSighashType { + use crate::fixed_script_wallet::wallet_scripts::Chain; + use miniscript::bitcoin::sighash::{EcdsaSighashType, TapSighashType}; + + // For taproot, always use Default + if matches!( + chain, + Chain::P2trInternal + | Chain::P2trExternal + | Chain::P2trMusig2Internal + | Chain::P2trMusig2External + ) { + return TapSighashType::Default.into(); + } + + // For non-taproot, check if network uses FORKID + let uses_forkid = matches!( + network.mainnet(), + Network::BitcoinCash | Network::BitcoinGold | Network::BitcoinSV | Network::Ecash + ); + + if uses_forkid { + // BCH/BSV/BTG/Ecash: SIGHASH_ALL | SIGHASH_FORKID = 0x41 + miniscript::bitcoin::psbt::PsbtSighashType::from_u32(0x41) + } else { + // Standard Bitcoin: SIGHASH_ALL + EcdsaSighashType::All.into() + } +} + +/// Create BIP32 derivation map for all 3 wallet keys +fn create_bip32_derivation( + wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, + chain: u32, + index: u32, +) -> std::collections::BTreeMap< + miniscript::bitcoin::secp256k1::PublicKey, + ( + miniscript::bitcoin::bip32::Fingerprint, + miniscript::bitcoin::bip32::DerivationPath, + ), +> { + use crate::fixed_script_wallet::derivation_path; + use miniscript::bitcoin::secp256k1::{PublicKey, Secp256k1}; + use std::collections::BTreeMap; + + let secp = Secp256k1::new(); + let mut map = BTreeMap::new(); + + for (i, xpub) in wallet_keys.xpubs.iter().enumerate() { + let path = derivation_path(&wallet_keys.derivation_prefixes[i], chain, index); + let derived = xpub.derive_pub(&secp, &path).expect("valid derivation"); + // Convert CompressedPublicKey to secp256k1::PublicKey + let pubkey = PublicKey::from_slice(&derived.to_pub().to_bytes()).expect("valid public key"); + map.insert(pubkey, (xpub.fingerprint(), path)); + } + + map +} + +/// Create tap key origins for specified key indices +fn create_tap_bip32_derivation( + wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, + chain: u32, + index: u32, + key_indices: &[usize], + leaf_hash: Option, +) -> std::collections::BTreeMap< + miniscript::bitcoin::XOnlyPublicKey, + ( + Vec, + ( + miniscript::bitcoin::bip32::Fingerprint, + miniscript::bitcoin::bip32::DerivationPath, + ), + ), +> { + use crate::fixed_script_wallet::derivation_path; + use miniscript::bitcoin::secp256k1::{PublicKey, Secp256k1}; + use std::collections::BTreeMap; + + let secp = Secp256k1::new(); + let mut map = BTreeMap::new(); + + for &i in key_indices { + let xpub = &wallet_keys.xpubs[i]; + let path = derivation_path(&wallet_keys.derivation_prefixes[i], chain, index); + let derived = xpub.derive_pub(&secp, &path).expect("valid derivation"); + // Convert CompressedPublicKey to secp256k1::PublicKey, then get x-only + let pubkey = PublicKey::from_slice(&derived.to_pub().to_bytes()).expect("valid public key"); + let (x_only, _parity) = pubkey.x_only_public_key(); + + let leaf_hashes = match leaf_hash { + Some(hash) => vec![hash], + None => vec![], + }; + + map.insert(x_only, (leaf_hashes, (xpub.fingerprint(), path))); + } + + map +} + impl BitGoPsbt { /// Deserialize a PSBT from bytes, using network-specific logic pub fn deserialize(psbt_bytes: &[u8], network: Network) -> Result { @@ -197,6 +306,538 @@ impl BitGoPsbt { } } + /// Create an empty PSBT with the given network and wallet keys + /// + /// # Arguments + /// * `network` - The network this PSBT is for + /// * `wallet_keys` - The wallet's root keys (used to set global xpubs) + /// * `version` - Transaction version (default: 2) + /// * `lock_time` - Lock time (default: 0) + pub fn new( + network: Network, + wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, + version: Option, + lock_time: Option, + ) -> Self { + use miniscript::bitcoin::{ + absolute::LockTime, bip32::DerivationPath, transaction::Version, Transaction, + }; + use std::collections::BTreeMap; + use std::str::FromStr; + + let tx = Transaction { + version: Version(version.unwrap_or(2)), + lock_time: LockTime::from_consensus(lock_time.unwrap_or(0)), + input: vec![], + output: vec![], + }; + + let mut psbt = Psbt::from_unsigned_tx(tx).expect("empty transaction should be valid"); + + // Set global xpubs from wallet keys + // Each xpub is mapped to (master_fingerprint, derivation_path) + // We use 'm' as the path since these are the root wallet keys + let mut xpub_map = BTreeMap::new(); + for xpub in &wallet_keys.xpubs { + let fingerprint = xpub.fingerprint(); + let path = DerivationPath::from_str("m").expect("'m' is a valid path"); + xpub_map.insert(*xpub, (fingerprint, path)); + } + psbt.xpub = xpub_map; + + match network { + Network::Zcash | Network::ZcashTestnet => BitGoPsbt::Zcash( + ZcashPsbt { + psbt, + version_group_id: None, + expiry_height: None, + sapling_fields: vec![], + }, + network, + ), + _ => BitGoPsbt::BitcoinLike(psbt, network), + } + } + + /// Add an input to the PSBT + /// + /// This adds a transaction input and corresponding PSBT input metadata. + /// The witness_utxo is automatically populated for modern signing compatibility. + /// + /// # Arguments + /// * `txid` - The transaction ID of the output being spent + /// * `vout` - The output index being spent + /// * `value` - The value in satoshis of the output being spent + /// * `script` - The output script (scriptPubKey) of the output being spent + /// * `sequence` - Optional sequence number (default: 0xFFFFFFFE for RBF) + /// + /// # Returns + /// The index of the newly added input + pub fn add_input( + &mut self, + txid: Txid, + vout: u32, + value: u64, + script: miniscript::bitcoin::ScriptBuf, + sequence: Option, + prev_tx: Option, + ) -> usize { + use miniscript::bitcoin::{transaction::Sequence, Amount, OutPoint, TxIn, TxOut}; + + let psbt = self.psbt_mut(); + + // Create the transaction input + let tx_in = TxIn { + previous_output: OutPoint { txid, vout }, + script_sig: miniscript::bitcoin::ScriptBuf::new(), + sequence: Sequence(sequence.unwrap_or(0xFFFFFFFE)), + witness: miniscript::bitcoin::Witness::default(), + }; + + // Create the PSBT input with witness_utxo populated + let psbt_input = miniscript::bitcoin::psbt::Input { + witness_utxo: Some(TxOut { + value: Amount::from_sat(value), + script_pubkey: script, + }), + non_witness_utxo: prev_tx, + ..Default::default() + }; + + // Add to the PSBT + psbt.unsigned_tx.input.push(tx_in); + psbt.inputs.push(psbt_input); + + psbt.inputs.len() - 1 + } + + /// Add a replay protection input (p2shP2pk) to the PSBT + /// + /// This creates a Pay-to-Script-Hash wrapped Pay-to-Public-Key input, + /// commonly used for replay protection on forked networks (BCH, BTG, etc.). + /// + /// # Arguments + /// * `pubkey` - The public key for the p2pk script + /// * `txid` - The transaction ID of the output being spent + /// * `vout` - The output index being spent + /// * `value` - The value in satoshis of the output being spent + /// * `options` - Optional parameters (sequence, sighash_type, prev_tx) + /// + /// # Returns + /// The index of the newly added input + pub fn add_replay_protection_input( + &mut self, + pubkey: miniscript::bitcoin::CompressedPublicKey, + txid: Txid, + vout: u32, + value: u64, + options: ReplayProtectionOptions, + ) -> usize { + use crate::fixed_script_wallet::wallet_scripts::ScriptP2shP2pk; + use miniscript::bitcoin::consensus::Decodable; + use miniscript::bitcoin::psbt::{Input, PsbtSighashType}; + use miniscript::bitcoin::{ + transaction::Sequence, Amount, OutPoint, Transaction, TxIn, TxOut, + }; + + let network = self.network(); + let psbt = self.psbt_mut(); + + // Create the p2shP2pk script + let script = ScriptP2shP2pk::new(pubkey); + let output_script = script.output_script(); + let redeem_script = script.redeem_script; + + // Create the transaction input + let tx_in = TxIn { + previous_output: OutPoint { txid, vout }, + script_sig: miniscript::bitcoin::ScriptBuf::new(), + sequence: Sequence(options.sequence.unwrap_or(0xFFFFFFFE)), + witness: miniscript::bitcoin::Witness::default(), + }; + + // Determine sighash type: use provided value or default based on network + // Networks with SIGHASH_FORKID use SIGHASH_ALL | SIGHASH_FORKID (0x41) + let sighash_type = options.sighash_type.unwrap_or_else(|| { + match network.mainnet() { + Network::BitcoinCash + | Network::Ecash + | Network::BitcoinSV + | Network::BitcoinGold => { + PsbtSighashType::from_u32(0x41) // SIGHASH_ALL | SIGHASH_FORKID + } + _ => PsbtSighashType::from_u32(0x01), // SIGHASH_ALL + } + }); + + // Create the PSBT input + let mut psbt_input = Input { + redeem_script: Some(redeem_script), + sighash_type: Some(sighash_type), + ..Default::default() + }; + + // Set utxo: either non_witness_utxo (full tx) or witness_utxo (output only) + if let Some(tx_bytes) = options.prev_tx { + let tx = Transaction::consensus_decode(&mut &tx_bytes[..]) + .expect("Failed to decode prev_tx"); + psbt_input.non_witness_utxo = Some(tx); + } else { + psbt_input.witness_utxo = Some(TxOut { + value: Amount::from_sat(value), + script_pubkey: output_script, + }); + } + + // Add to the PSBT + psbt.unsigned_tx.input.push(tx_in); + psbt.inputs.push(psbt_input); + + psbt.inputs.len() - 1 + } + + /// Add an output to the PSBT + /// + /// # Arguments + /// * `script` - The output script (scriptPubKey) + /// * `value` - The value in satoshis + /// + /// # Returns + /// The index of the newly added output + pub fn add_output(&mut self, script: miniscript::bitcoin::ScriptBuf, value: u64) -> usize { + use miniscript::bitcoin::{Amount, TxOut}; + + let psbt = self.psbt_mut(); + + // Create the transaction output + let tx_out = TxOut { + value: Amount::from_sat(value), + script_pubkey: script, + }; + + // Create the PSBT output + let psbt_output = miniscript::bitcoin::psbt::Output::default(); + + // Add to the PSBT + psbt.unsigned_tx.output.push(tx_out); + psbt.outputs.push(psbt_output); + + psbt.outputs.len() - 1 + } + + /// Add a wallet input with full PSBT metadata + /// + /// This is a higher-level method that adds an input and populates all required + /// PSBT fields (scripts, derivation info, etc.) based on the wallet's chain type. + /// + /// # Arguments + /// * `txid` - The transaction ID of the output being spent + /// * `vout` - The output index being spent + /// * `value` - The value in satoshis + /// * `wallet_keys` - The root wallet keys + /// * `script_id` - The chain and index identifying the script + /// * `options` - Optional parameters (sign_path, sequence, prev_tx) + /// + /// # Returns + /// The index of the newly added input + pub fn add_wallet_input( + &mut self, + txid: Txid, + vout: u32, + value: u64, + wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, + script_id: psbt_wallet_input::ScriptId, + options: WalletInputOptions, + ) -> Result { + use crate::fixed_script_wallet::to_pub_triple; + use crate::fixed_script_wallet::wallet_scripts::{Chain, WalletScripts}; + use miniscript::bitcoin::psbt::Input; + use miniscript::bitcoin::taproot::{LeafVersion, TapLeafHash}; + use miniscript::bitcoin::{transaction::Sequence, Amount, OutPoint, TxIn, TxOut}; + use p2tr_musig2_input::Musig2Participants; + use std::convert::TryFrom; + + let network = self.network(); + let psbt = self.psbt_mut(); + + let chain = script_id.chain; + let index = script_id.index; + + // Parse chain + let chain_enum = Chain::try_from(chain)?; + + // Derive wallet keys for this chain/index + let derived_keys = wallet_keys + .derive_for_chain_and_index(chain, index) + .map_err(|e| format!("Failed to derive keys: {}", e))?; + let pub_triple = to_pub_triple(&derived_keys); + + // Create wallet scripts + let script_support = network.output_script_support(); + let scripts = WalletScripts::new(&pub_triple, chain_enum, &script_support) + .map_err(|e| format!("Failed to create wallet scripts: {}", e))?; + + // Get the output script + let output_script = scripts.output_script(); + + // Create the transaction input + let tx_in = TxIn { + previous_output: OutPoint { txid, vout }, + script_sig: miniscript::bitcoin::ScriptBuf::new(), + sequence: Sequence(options.sequence.unwrap_or(0xFFFFFFFE)), + witness: miniscript::bitcoin::Witness::default(), + }; + + // Create the PSBT input + let mut psbt_input = Input::default(); + + // Determine if segwit based on chain type + let is_segwit = matches!( + chain_enum, + Chain::P2shP2wshExternal + | Chain::P2shP2wshInternal + | Chain::P2wshExternal + | Chain::P2wshInternal + | Chain::P2trInternal + | Chain::P2trExternal + | Chain::P2trMusig2Internal + | Chain::P2trMusig2External + ); + + if let (false, Some(tx_bytes)) = (is_segwit, options.prev_tx) { + // Non-segwit with prev_tx: use non_witness_utxo + psbt_input.non_witness_utxo = Some( + miniscript::bitcoin::consensus::deserialize(tx_bytes) + .map_err(|e| format!("Failed to deserialize previous transaction: {}", e))?, + ); + } else { + // Segwit or non-segwit without prev_tx: use witness_utxo + psbt_input.witness_utxo = Some(TxOut { + value: Amount::from_sat(value), + script_pubkey: output_script.clone(), + }); + } + + // Set sighash type based on network + let sighash_type = get_default_sighash_type(network, chain_enum); + psbt_input.sighash_type = Some(sighash_type); + + // Populate script-type-specific metadata + match &scripts { + WalletScripts::P2sh(script) => { + // bip32_derivation for all 3 keys + psbt_input.bip32_derivation = create_bip32_derivation(wallet_keys, chain, index); + // redeem_script + psbt_input.redeem_script = Some(script.redeem_script.clone()); + } + WalletScripts::P2shP2wsh(script) => { + // bip32_derivation for all 3 keys + psbt_input.bip32_derivation = create_bip32_derivation(wallet_keys, chain, index); + // witness_script and redeem_script + psbt_input.witness_script = Some(script.witness_script.clone()); + psbt_input.redeem_script = Some(script.redeem_script.clone()); + } + WalletScripts::P2wsh(script) => { + // bip32_derivation for all 3 keys + psbt_input.bip32_derivation = create_bip32_derivation(wallet_keys, chain, index); + // witness_script + psbt_input.witness_script = Some(script.witness_script.clone()); + } + WalletScripts::P2trLegacy(script) | WalletScripts::P2trMusig2(script) => { + // For taproot, sign_path is required + let sign_path = options.sign_path.ok_or_else(|| { + "sign_path is required for p2tr/p2trMusig2 inputs".to_string() + })?; + let signer_idx = sign_path.signer.index(); + let cosigner_idx = sign_path.cosigner.index(); + + let is_musig2 = matches!(scripts, WalletScripts::P2trMusig2(_)); + let is_backup_flow = sign_path.signer.is_backup() || sign_path.cosigner.is_backup(); + + if !is_musig2 || is_backup_flow { + // Script path spending (p2tr or p2trMusig2 with backup) + // Get the leaf script for signer/cosigner pair + let signer_keys = [pub_triple[signer_idx], pub_triple[cosigner_idx]]; + let leaf_script = + crate::fixed_script_wallet::wallet_scripts::build_p2tr_ns_script( + &signer_keys, + ); + let leaf_hash = TapLeafHash::from_script(&leaf_script, LeafVersion::TapScript); + + // Find the control block for this leaf + let control_block = script + .spend_info + .control_block(&(leaf_script.clone(), LeafVersion::TapScript)) + .ok_or_else(|| { + "Could not find control block for leaf script".to_string() + })?; + + // Set tap_leaf_script + psbt_input.tap_scripts.insert( + control_block.clone(), + (leaf_script.clone(), LeafVersion::TapScript), + ); + + // Set tap_bip32_derivation for signer and cosigner + psbt_input.tap_key_origins = create_tap_bip32_derivation( + wallet_keys, + chain, + index, + &[signer_idx, cosigner_idx], + Some(leaf_hash), + ); + } else { + // Key path spending (p2trMusig2 with user/bitgo) + let internal_key = script.spend_info.internal_key(); + let merkle_root = script.spend_info.merkle_root(); + + // Set tap_internal_key + psbt_input.tap_internal_key = Some(internal_key); + + // Set tap_merkle_root + psbt_input.tap_merkle_root = merkle_root; + + // Set tap_bip32_derivation for signer and cosigner (no leaf hashes for key path) + psbt_input.tap_key_origins = create_tap_bip32_derivation( + wallet_keys, + chain, + index, + &[signer_idx, cosigner_idx], + None, + ); + + // Set musig2 participant pubkeys (proprietary field) + let user_key = pub_triple[0]; // user is index 0 + let bitgo_key = pub_triple[2]; // bitgo is index 2 + + // Create musig2 participants + let tap_output_key = script.spend_info.output_key().to_x_only_public_key(); + let musig2_participants = Musig2Participants { + tap_output_key, + tap_internal_key: internal_key, + participant_pub_keys: [user_key, bitgo_key], + }; + + // Add to proprietary keys + let (key, value) = musig2_participants.to_key_value().to_key_value(); + psbt_input.proprietary.insert(key, value); + } + } + } + + // Add to PSBT + psbt.unsigned_tx.input.push(tx_in); + psbt.inputs.push(psbt_input); + + Ok(psbt.inputs.len() - 1) + } + + /// Add a wallet output with full PSBT metadata + /// + /// This creates a verifiable wallet output (typically for change) with all required + /// PSBT fields (scripts, derivation info) based on the wallet's chain type. + /// + /// # Arguments + /// * `chain` - The chain code (determines script type: 0/1=p2sh, 10/11=p2shP2wsh, etc.) + /// * `index` - The derivation index + /// * `value` - The value in satoshis + /// * `wallet_keys` - The root wallet keys + /// + /// # Returns + /// The index of the newly added output + pub fn add_wallet_output( + &mut self, + chain: u32, + index: u32, + value: u64, + wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, + ) -> Result { + use crate::fixed_script_wallet::to_pub_triple; + use crate::fixed_script_wallet::wallet_scripts::{ + build_tap_tree_for_output, create_tap_bip32_derivation_for_output, Chain, WalletScripts, + }; + use miniscript::bitcoin::psbt::Output; + use miniscript::bitcoin::{Amount, TxOut}; + use std::convert::TryFrom; + + let network = self.network(); + let psbt = self.psbt_mut(); + + // Parse chain + let chain_enum = Chain::try_from(chain)?; + + // Derive wallet keys for this chain/index + let derived_keys = wallet_keys + .derive_for_chain_and_index(chain, index) + .map_err(|e| format!("Failed to derive keys: {}", e))?; + let pub_triple = to_pub_triple(&derived_keys); + + // Create wallet scripts + let script_support = network.output_script_support(); + let scripts = WalletScripts::new(&pub_triple, chain_enum, &script_support) + .map_err(|e| format!("Failed to create wallet scripts: {}", e))?; + + // Get the output script + let output_script = scripts.output_script(); + + // Create the transaction output + let tx_out = TxOut { + value: Amount::from_sat(value), + script_pubkey: output_script, + }; + + // Create the PSBT output with metadata + let mut psbt_output = Output::default(); + + // Populate script-type-specific metadata + match &scripts { + WalletScripts::P2sh(script) => { + // bip32_derivation for all 3 keys + psbt_output.bip32_derivation = create_bip32_derivation(wallet_keys, chain, index); + // redeem_script + psbt_output.redeem_script = Some(script.redeem_script.clone()); + } + WalletScripts::P2shP2wsh(script) => { + // bip32_derivation for all 3 keys + psbt_output.bip32_derivation = create_bip32_derivation(wallet_keys, chain, index); + // witness_script and redeem_script + psbt_output.witness_script = Some(script.witness_script.clone()); + psbt_output.redeem_script = Some(script.redeem_script.clone()); + } + WalletScripts::P2wsh(script) => { + // bip32_derivation for all 3 keys + psbt_output.bip32_derivation = create_bip32_derivation(wallet_keys, chain, index); + // witness_script + psbt_output.witness_script = Some(script.witness_script.clone()); + } + WalletScripts::P2trLegacy(script) | WalletScripts::P2trMusig2(script) => { + let is_musig2 = matches!(scripts, WalletScripts::P2trMusig2(_)); + + // Set tap_internal_key + let internal_key = script.spend_info.internal_key(); + psbt_output.tap_internal_key = Some(internal_key); + + // Set tap_tree for the output + psbt_output.tap_tree = Some(build_tap_tree_for_output(&pub_triple, is_musig2)); + + // Set tap_bip32_derivation with correct leaf hashes for each key + psbt_output.tap_key_origins = create_tap_bip32_derivation_for_output( + wallet_keys, + chain, + index, + &pub_triple, + is_musig2, + ); + } + } + + // Add to PSBT + psbt.unsigned_tx.output.push(tx_out); + psbt.outputs.push(psbt_output); + + Ok(psbt.outputs.len() - 1) + } + pub fn network(&self) -> Network { match self { BitGoPsbt::BitcoinLike(_, network) => *network, @@ -2289,4 +2930,411 @@ mod tests { let serialized = psbt.serialize(); assert!(serialized.is_ok(), "Serialization should succeed"); } + + /// Test reconstructing PSBTs from fixture data using builder methods + fn test_psbt_reconstruction_for_network(network: Network, format: fixtures::TxFormat) { + use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::InputScriptType; + use crate::fixed_script_wallet::ReplayProtection; + + // Load fixture with specified format + let fixture = fixtures::load_psbt_fixture_with_format( + network.to_utxolib_name(), + fixtures::SignatureState::Unsigned, + format, + ) + .expect("Failed to load fixture"); + + // Get wallet keys (main wallet from fixture) + let wallet_xprvs = fixture.get_wallet_xprvs().expect("Failed to get xprvs"); + let wallet_keys = wallet_xprvs.to_root_wallet_keys(); + + // Create other wallet keys for outputs from different wallet + // This matches utxo-lib's getWalletKeysForSeed('too many secrets') + use crate::fixed_script_wallet::test_utils::get_test_wallet_keys; + let other_wallet_keys = crate::fixed_script_wallet::RootWalletKeys::new( + get_test_wallet_keys("too many secrets"), + ); + + // Load the original PSBT and parse inputs/outputs using existing methods + let original_psbt = fixture + .to_bitgo_psbt(network) + .expect("Failed to load original"); + + // Extract replay protection output scripts from inputs without derivation info + // These are typically p2shP2pk inputs used for replay protection on forks + // Handle both witness_utxo (psbt-lite) and non_witness_utxo (full psbt) formats + let replay_protection_scripts: Vec = original_psbt + .psbt() + .inputs + .iter() + .zip(original_psbt.psbt().unsigned_tx.input.iter()) + .filter(|(input, _)| { + input.bip32_derivation.is_empty() && input.tap_key_origins.is_empty() + }) + .filter_map(|(input, tx_in)| { + // Try witness_utxo first, then fall back to non_witness_utxo + input + .witness_utxo + .as_ref() + .map(|utxo| utxo.script_pubkey.clone()) + .or_else(|| { + input.non_witness_utxo.as_ref().and_then(|prev_tx| { + prev_tx + .output + .get(tx_in.previous_output.vout as usize) + .map(|out| out.script_pubkey.clone()) + }) + }) + }) + .collect(); + + let replay_protection = ReplayProtection::new(replay_protection_scripts); + let parsed_inputs = original_psbt + .parse_inputs(&wallet_keys, &replay_protection) + .expect("Failed to parse inputs"); + + // Parse outputs with main wallet keys + let parsed_outputs = original_psbt + .parse_outputs(&wallet_keys, &[]) + .expect("Failed to parse outputs"); + + // Parse outputs with other wallet keys to identify outputs from different wallet + let parsed_outputs_other = original_psbt + .parse_outputs(&other_wallet_keys, &[]) + .expect("Failed to parse outputs with other wallet keys"); + + // Create empty PSBT with same version and locktime as original + let original_version = original_psbt.psbt().unsigned_tx.version.0 as i32; + let original_locktime = original_psbt + .psbt() + .unsigned_tx + .lock_time + .to_consensus_u32(); + let mut reconstructed = BitGoPsbt::new( + network, + &wallet_keys, + Some(original_version), + Some(original_locktime), + ); + + // Track which inputs are wallet inputs vs replay protection + let mut wallet_input_indices = Vec::new(); + // Track which outputs are from our wallet keys + let mut wallet_output_indices = Vec::new(); + + // Add inputs using parsed data + let original_tx = original_psbt.psbt().unsigned_tx.clone(); + let original_psbt_inputs = &original_psbt.psbt().inputs; + for (input_idx, ((tx_in, parsed_input), orig_psbt_input)) in original_tx + .input + .iter() + .zip(parsed_inputs.iter()) + .zip(original_psbt_inputs.iter()) + .enumerate() + { + let txid = tx_in.previous_output.txid; + let vout = tx_in.previous_output.vout; + let value = parsed_input.value; + let sequence = tx_in.sequence.0; + + if let Some(script_id) = parsed_input.script_id { + wallet_input_indices.push(input_idx); + + // Determine sign_path based on script type (required for Taproot) + use psbt_wallet_input::SignerKey; + let sign_path = match parsed_input.script_type { + InputScriptType::P2trLegacy => Some(psbt_wallet_input::SignPath { + signer: SignerKey::User, + cosigner: SignerKey::Bitgo, + }), + InputScriptType::P2trMusig2ScriptPath => Some(psbt_wallet_input::SignPath { + signer: SignerKey::User, + cosigner: SignerKey::Backup, + }), + InputScriptType::P2trMusig2KeyPath => Some(psbt_wallet_input::SignPath { + signer: SignerKey::User, + cosigner: SignerKey::Bitgo, + }), + _ => None, + }; + + // For full PSBT format, non-segwit inputs need non_witness_utxo + // Serialize the prev_tx from the original input if present + let prev_tx: Option> = orig_psbt_input + .non_witness_utxo + .as_ref() + .map(|tx| miniscript::bitcoin::consensus::serialize(tx)); + + let result = reconstructed.add_wallet_input( + txid, + vout, + value, + &wallet_keys, + script_id, + WalletInputOptions { + sign_path, + sequence: Some(sequence), + prev_tx: prev_tx.as_deref(), + }, + ); + assert!( + result.is_ok(), + "Failed to add wallet input {}: {:?}", + input_idx, + result + ); + } else { + // Non-wallet input (replay protection) - extract pubkey and use add_replay_protection_input + let redeem_script = orig_psbt_input + .redeem_script + .as_ref() + .expect("Replay protection input should have redeem_script"); + let pubkey = BitGoPsbt::extract_pubkey_from_p2pk_redeem_script(redeem_script) + .expect("Failed to extract pubkey from redeem_script"); + let compressed_pubkey = miniscript::bitcoin::CompressedPublicKey(pubkey.inner); + + // For full PSBT format, serialize the non_witness_utxo + let prev_tx = orig_psbt_input + .non_witness_utxo + .as_ref() + .map(|tx| miniscript::bitcoin::consensus::encode::serialize(tx)); + + reconstructed.add_replay_protection_input( + compressed_pubkey, + txid, + vout, + value, + ReplayProtectionOptions { + sequence: Some(sequence), + sighash_type: orig_psbt_input.sighash_type, + prev_tx: prev_tx.as_deref(), + }, + ); + } + } + + // Add outputs using parsed data from both wallet key sets + for (output_idx, ((tx_out, parsed_output), parsed_output_other)) in original_tx + .output + .iter() + .zip(parsed_outputs.iter()) + .zip(parsed_outputs_other.iter()) + .enumerate() + { + let value = parsed_output.value; + + if let Some(script_id) = &parsed_output.script_id { + // Output belongs to main wallet + wallet_output_indices.push(output_idx); + let result = reconstructed.add_wallet_output( + script_id.chain, + script_id.index, + value, + &wallet_keys, + ); + assert!( + result.is_ok(), + "Failed to add wallet output {}: {:?}", + output_idx, + result + ); + } else if let Some(script_id) = &parsed_output_other.script_id { + // Output belongs to other wallet (from seed "too many secrets") + wallet_output_indices.push(output_idx); + let result = reconstructed.add_wallet_output( + script_id.chain, + script_id.index, + value, + &other_wallet_keys, + ); + assert!( + result.is_ok(), + "Failed to add other wallet output {}: {:?}", + output_idx, + result + ); + } else { + // External output - use add_output + let _idx = reconstructed.add_output(tx_out.script_pubkey.clone(), value); + } + } + + // Compare the unsigned transactions + let reconstructed_tx = &reconstructed.psbt().unsigned_tx; + + // Compare input count + assert_eq!( + original_tx.input.len(), + reconstructed_tx.input.len(), + "Input count mismatch" + ); + + // Compare output count + assert_eq!( + original_tx.output.len(), + reconstructed_tx.output.len(), + "Output count mismatch" + ); + + // Compare each input (transaction-level) + for (idx, (orig, recon)) in original_tx + .input + .iter() + .zip(reconstructed_tx.input.iter()) + .enumerate() + { + assert_eq!( + orig.previous_output, recon.previous_output, + "Input {} previous_output mismatch", + idx + ); + assert_eq!( + orig.sequence, recon.sequence, + "Input {} sequence mismatch", + idx + ); + } + + // Compare each output (transaction-level) + for (idx, (orig, recon)) in original_tx + .output + .iter() + .zip(reconstructed_tx.output.iter()) + .enumerate() + { + assert_eq!( + orig.script_pubkey, recon.script_pubkey, + "Output {} script_pubkey mismatch", + idx + ); + assert_eq!(orig.value, recon.value, "Output {} value mismatch", idx); + } + + // Compare PSBT input metadata (only for wallet inputs) + let original_psbt_inputs = &original_psbt.psbt().inputs; + let reconstructed_inputs = &reconstructed.psbt().inputs; + + for (idx, (orig, recon)) in original_psbt_inputs + .iter() + .zip(reconstructed_inputs.iter()) + .enumerate() + { + // Compare utxo fields - either witness_utxo or non_witness_utxo should match + // For segwit: witness_utxo is used + // For non-segwit with prev_tx: non_witness_utxo is used + // For non-segwit without prev_tx: witness_utxo is used as fallback + let orig_has_utxo = orig.witness_utxo.is_some() || orig.non_witness_utxo.is_some(); + let recon_has_utxo = recon.witness_utxo.is_some() || recon.non_witness_utxo.is_some(); + assert!( + orig_has_utxo && recon_has_utxo, + "Input {} missing utxo data", + idx + ); + + // If both have witness_utxo, compare them + if orig.witness_utxo.is_some() && recon.witness_utxo.is_some() { + assert_eq!( + orig.witness_utxo, recon.witness_utxo, + "Input {} witness_utxo mismatch", + idx + ); + } + + // If both have non_witness_utxo, compare the relevant output + if orig.non_witness_utxo.is_some() && recon.non_witness_utxo.is_some() { + let orig_tx = orig.non_witness_utxo.as_ref().unwrap(); + let recon_tx = recon.non_witness_utxo.as_ref().unwrap(); + let vout = original_tx.input[idx].previous_output.vout as usize; + assert_eq!( + orig_tx.output.get(vout), + recon_tx.output.get(vout), + "Input {} non_witness_utxo output mismatch", + idx + ); + } + + // Skip detailed metadata comparison for non-wallet inputs + if !wallet_input_indices.contains(&idx) { + continue; + } + + // For non-taproot wallet inputs, compare witness_script and redeem_script + if orig.witness_script.is_some() || orig.redeem_script.is_some() { + assert_eq!( + orig.witness_script, recon.witness_script, + "Input {} witness_script mismatch", + idx + ); + assert_eq!( + orig.redeem_script, recon.redeem_script, + "Input {} redeem_script mismatch", + idx + ); + } + + // For taproot wallet inputs, compare tap_internal_key + // (but not tap_leaf_script which depends on signer/cosigner choice) + if orig.tap_internal_key.is_some() { + assert_eq!( + orig.tap_internal_key, recon.tap_internal_key, + "Input {} tap_internal_key mismatch", + idx + ); + } + } + + // Compare PSBT output metadata (only for our wallet outputs) + let original_psbt_outputs = &original_psbt.psbt().outputs; + let reconstructed_outputs = &reconstructed.psbt().outputs; + + for (idx, (orig, recon)) in original_psbt_outputs + .iter() + .zip(reconstructed_outputs.iter()) + .enumerate() + { + // Skip metadata comparison for non-wallet outputs (external or from different keys) + if !wallet_output_indices.contains(&idx) { + continue; + } + + // For non-taproot wallet outputs, compare witness_script and redeem_script + if orig.witness_script.is_some() || orig.redeem_script.is_some() { + assert_eq!( + orig.witness_script, recon.witness_script, + "Output {} witness_script mismatch", + idx + ); + assert_eq!( + orig.redeem_script, recon.redeem_script, + "Output {} redeem_script mismatch", + idx + ); + } + + // For taproot wallet outputs, compare tap_internal_key + if orig.tap_internal_key.is_some() { + assert_eq!( + orig.tap_internal_key, recon.tap_internal_key, + "Output {} tap_internal_key mismatch", + idx + ); + } + } + + // Compare PSBTs at the key-value pair level for detailed error messages + use crate::fixed_script_wallet::test_utils::psbt_compare::assert_equal_psbt; + let original_bytes = original_psbt + .serialize() + .expect("Failed to serialize original"); + let reconstructed_bytes = reconstructed + .serialize() + .expect("Failed to serialize reconstructed"); + assert_equal_psbt(&original_bytes, &reconstructed_bytes); + } + + // Note: Only testing PsbtLite format for now because full PSBT format + // uses non_witness_utxo instead of witness_utxo for non-segwit inputs + crate::test_psbt_fixtures!(test_psbt_reconstruction, network, format, { + test_psbt_reconstruction_for_network(network, format); + }, ignore: [Zcash]); } 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 a4aaeb3..e9929fd 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 @@ -514,6 +514,75 @@ pub struct ScriptId { pub index: u32, } +/// Identifies a key in the wallet triple (user, backup, bitgo) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SignerKey { + User, + Backup, + Bitgo, +} + +impl SignerKey { + /// Returns the index of this key in the wallet triple + pub fn index(&self) -> usize { + match self { + SignerKey::User => 0, + SignerKey::Backup => 1, + SignerKey::Bitgo => 2, + } + } + + /// Check if this is the backup key + pub fn is_backup(&self) -> bool { + matches!(self, SignerKey::Backup) + } +} + +impl std::str::FromStr for SignerKey { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "user" => Ok(SignerKey::User), + "backup" => Ok(SignerKey::Backup), + "bitgo" => Ok(SignerKey::Bitgo), + _ => Err(format!( + "Invalid key name '{}': expected 'user', 'backup', or 'bitgo'", + s + )), + } + } +} + +/// Specifies the signer and cosigner for Taproot inputs +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SignPath { + pub signer: SignerKey, + pub cosigner: SignerKey, +} + +/// Optional parameters for replay protection inputs +#[derive(Debug, Clone, Default)] +pub struct ReplayProtectionOptions<'a> { + /// Sequence number (default: 0xFFFFFFFE for RBF) + pub sequence: Option, + /// Sighash type override (default: network-appropriate value) + pub sighash_type: Option, + /// Previous transaction bytes; if provided, uses non_witness_utxo + pub prev_tx: Option<&'a [u8]>, +} + +/// Optional parameters for wallet inputs +#[derive(Debug, Clone, Default)] +pub struct WalletInputOptions<'a> { + /// Signer and cosigner for Taproot inputs (required for p2tr/p2trMusig2) + pub sign_path: Option, + /// Sequence number (default: 0xFFFFFFFE for RBF) + pub sequence: Option, + /// Previous transaction bytes; if provided, uses non_witness_utxo + pub prev_tx: Option<&'a [u8]>, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InputScriptType { P2shP2pk, @@ -584,6 +653,7 @@ pub struct ParsedInput { pub value: u64, pub script_id: Option, pub script_type: InputScriptType, + pub sequence: u32, } impl ParsedInput { @@ -647,6 +717,7 @@ impl ParsedInput { value: value.to_sat(), script_id, script_type, + sequence: tx_input.sequence.0, }) } } diff --git a/packages/wasm-utxo/src/fixed_script_wallet/test_utils/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/test_utils/mod.rs index 1f1839c..7fc102e 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/test_utils/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/test_utils/mod.rs @@ -1,6 +1,7 @@ //! Test utilities for fixed_script_wallet module pub mod fixtures; +pub mod psbt_compare; use super::wallet_keys::XpubTriple; use super::wallet_scripts::{Chain, WalletScripts}; @@ -12,6 +13,7 @@ use std::collections::BTreeMap; use std::str::FromStr; /// Get test wallet xpubs from a seed string +/// This matches the TypeScript getWalletKeysForSeed function from keys.ts pub fn get_test_wallet_keys(seed: &str) -> XpubTriple { use crate::bitcoin::hashes::{sha256, Hash}; use crate::bitcoin::Network; @@ -21,9 +23,11 @@ pub fn get_test_wallet_keys(seed: &str) -> XpubTriple { Xpriv::new_master(Network::Testnet, &seed_hash).expect("could not create xpriv from seed") } - let a = get_xpriv_from_seed(&format!("{}/0", seed)); - let b = get_xpriv_from_seed(&format!("{}/1", seed)); - let c = get_xpriv_from_seed(&format!("{}/2", seed)); + // Note: TypeScript uses `.` separator (e.g., "seed.0", "seed.1", "seed.2") + // to match utxo-lib's getKeyTriple function in keys.ts + let a = get_xpriv_from_seed(&format!("{}.0", seed)); + let b = get_xpriv_from_seed(&format!("{}.1", seed)); + let c = get_xpriv_from_seed(&format!("{}.2", seed)); let secp = crate::bitcoin::secp256k1::Secp256k1::new(); [a, b, c].map(|x| Xpub::from_priv(&secp, &x)) diff --git a/packages/wasm-utxo/src/fixed_script_wallet/test_utils/psbt_compare.rs b/packages/wasm-utxo/src/fixed_script_wallet/test_utils/psbt_compare.rs new file mode 100644 index 0000000..86681bf --- /dev/null +++ b/packages/wasm-utxo/src/fixed_script_wallet/test_utils/psbt_compare.rs @@ -0,0 +1,517 @@ +//! PSBT comparison utilities for testing +//! +//! This module provides low-level PSBT parsing and comparison utilities that work +//! at the key-value pair level, providing detailed error messages showing exactly +//! which fields differ between two PSBTs. +//! +//! # Example +//! +//! ```ignore +//! use crate::fixed_script_wallet::test_utils::psbt_compare::assert_equal_psbt; +//! +//! let original_bytes = original_psbt.serialize().unwrap(); +//! let reconstructed_bytes = reconstructed_psbt.serialize().unwrap(); +//! +//! assert_equal_psbt(&original_bytes, &reconstructed_bytes); +//! ``` + +use miniscript::bitcoin::consensus::Decodable; +use miniscript::bitcoin::{Transaction, VarInt}; +use std::collections::HashMap; + +/// Context for interpreting PSBT key types +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PsbtMapContext { + Global, + Input, + Output, +} + +/// A raw PSBT key-value pair +#[derive(Debug, Clone)] +pub struct RawPair { + pub type_value: u8, + pub key: Vec, + pub value: Vec, +} + +/// A parsed PSBT map (global, input, or output) +#[derive(Debug)] +pub struct ParsedMap { + pub pairs: Vec, +} + +impl ParsedMap { + /// Check if a key type is present + pub fn has_type(&self, type_value: u8) -> bool { + self.pairs.iter().any(|p| p.type_value == type_value) + } +} + +/// A fully parsed PSBT structure +#[derive(Debug)] +pub struct ParsedPsbt { + pub global: ParsedMap, + pub inputs: Vec, + pub outputs: Vec, +} + +/// Decode a varint from bytes, returns (value, bytes_consumed) +fn decode_varint(bytes: &[u8], pos: usize) -> Result<(u64, usize), String> { + if pos >= bytes.len() { + return Err("Not enough bytes for varint".to_string()); + } + + let mut cursor = &bytes[pos..]; + let varint = VarInt::consensus_decode(&mut cursor) + .map_err(|e| format!("Failed to decode varint: {}", e))?; + + let bytes_consumed = bytes.len() - pos - cursor.len(); + Ok((varint.0, bytes_consumed)) +} + +/// Decode a single key-value pair from bytes +fn decode_pair(bytes: &[u8], pos: usize) -> Result<(RawPair, usize), String> { + let mut current_pos = pos; + + // Decode key length (varint) + let (key_len, varint_size) = decode_varint(bytes, current_pos)?; + current_pos += varint_size; + + if key_len == 0 { + return Err("Zero-length key (map separator)".to_string()); + } + + // Key is: type_value (1 byte) + key_data + if current_pos >= bytes.len() { + return Err("Not enough bytes for key type".to_string()); + } + + let type_value = bytes[current_pos]; + current_pos += 1; + + let key_data_len = (key_len - 1) as usize; + if current_pos + key_data_len > bytes.len() { + return Err(format!( + "Not enough bytes for key data: need {}, have {}", + key_data_len, + bytes.len() - current_pos + )); + } + + let mut key = vec![type_value]; + key.extend_from_slice(&bytes[current_pos..current_pos + key_data_len]); + current_pos += key_data_len; + + // Decode value length (varint) + let (value_len, varint_size) = decode_varint(bytes, current_pos)?; + current_pos += varint_size; + + let value_len = value_len as usize; + if current_pos + value_len > bytes.len() { + return Err(format!( + "Not enough bytes for value: need {}, have {}", + value_len, + bytes.len() - current_pos + )); + } + + let value = bytes[current_pos..current_pos + value_len].to_vec(); + current_pos += value_len; + + Ok(( + RawPair { + type_value, + key, + value, + }, + current_pos - pos, + )) +} + +/// Extract transaction input/output counts from global map +fn extract_tx_counts(global_pairs: &[RawPair]) -> Result<(usize, usize), String> { + for pair in global_pairs { + if pair.type_value == 0x00 { + let tx = Transaction::consensus_decode(&mut &pair.value[..]) + .map_err(|e| format!("Failed to decode unsigned transaction: {}", e))?; + return Ok((tx.input.len(), tx.output.len())); + } + } + Err("No unsigned transaction found in global map".to_string()) +} + +/// Decode a single map (set of key-value pairs terminated by 0x00) +fn decode_map_pairs(bytes: &[u8], start_pos: usize) -> Result<(Vec, usize), String> { + let mut pairs = Vec::new(); + let mut pos = start_pos; + + loop { + if pos >= bytes.len() { + break; + } + + if bytes[pos] == 0x00 { + pos += 1; + break; + } + + match decode_pair(bytes, pos) { + Ok((pair, consumed)) => { + pairs.push(pair); + pos += consumed; + } + Err(e) => { + if e.contains("Zero-length") { + pos += 1; + break; + } + return Err(format!("Failed to decode pair at position {}: {}", pos, e)); + } + } + } + + Ok((pairs, pos)) +} + +/// Parse PSBT bytes into a structured ParsedPsbt +pub fn parse_psbt_to_maps(bytes: &[u8]) -> Result { + // Check magic bytes + if bytes.len() < 5 { + return Err("PSBT too short to contain magic bytes".to_string()); + } + + let magic = &bytes[0..5]; + if magic != b"psbt\xff" { + return Err(format!("Invalid PSBT magic bytes: {:02x?}", magic)); + } + + let mut pos = 5; + + // Decode global map + let (global_pairs, new_pos) = decode_map_pairs(bytes, pos)?; + pos = new_pos; + + // Extract input/output counts + let (input_count, output_count) = extract_tx_counts(&global_pairs)?; + + let global = ParsedMap { + pairs: global_pairs, + }; + + // Decode input maps + let mut inputs = Vec::with_capacity(input_count); + for _ in 0..input_count { + let (pairs, new_pos) = decode_map_pairs(bytes, pos)?; + pos = new_pos; + inputs.push(ParsedMap { pairs }); + } + + // Decode output maps + let mut outputs = Vec::with_capacity(output_count); + for _ in 0..output_count { + let (pairs, new_pos) = decode_map_pairs(bytes, pos)?; + pos = new_pos; + outputs.push(ParsedMap { pairs }); + } + + Ok(ParsedPsbt { + global, + inputs, + outputs, + }) +} + +/// Get human-readable name for PSBT key type based on context +fn key_type_name(type_id: u8, context: PsbtMapContext) -> String { + match context { + PsbtMapContext::Global => match type_id { + 0x00 => "PSBT_GLOBAL_UNSIGNED_TX".to_string(), + 0x01 => "PSBT_GLOBAL_XPUB".to_string(), + 0x02 => "PSBT_GLOBAL_TX_VERSION".to_string(), + 0x03 => "PSBT_GLOBAL_FALLBACK_LOCKTIME".to_string(), + 0x04 => "PSBT_GLOBAL_INPUT_COUNT".to_string(), + 0x05 => "PSBT_GLOBAL_OUTPUT_COUNT".to_string(), + 0x06 => "PSBT_GLOBAL_TX_MODIFIABLE".to_string(), + 0x07 => "PSBT_GLOBAL_VERSION".to_string(), + 0xFC => "PSBT_GLOBAL_PROPRIETARY".to_string(), + _ => format!("UNKNOWN_TYPE_0x{:02X}", type_id), + }, + PsbtMapContext::Input => match type_id { + 0x00 => "PSBT_IN_NON_WITNESS_UTXO".to_string(), + 0x01 => "PSBT_IN_WITNESS_UTXO".to_string(), + 0x02 => "PSBT_IN_PARTIAL_SIG".to_string(), + 0x03 => "PSBT_IN_SIGHASH_TYPE".to_string(), + 0x04 => "PSBT_IN_REDEEM_SCRIPT".to_string(), + 0x05 => "PSBT_IN_WITNESS_SCRIPT".to_string(), + 0x06 => "PSBT_IN_BIP32_DERIVATION".to_string(), + 0x07 => "PSBT_IN_FINAL_SCRIPTSIG".to_string(), + 0x08 => "PSBT_IN_FINAL_SCRIPTWITNESS".to_string(), + 0x09 => "PSBT_IN_POR_COMMITMENT".to_string(), + 0x0a => "PSBT_IN_RIPEMD160".to_string(), + 0x0b => "PSBT_IN_SHA256".to_string(), + 0x0c => "PSBT_IN_HASH160".to_string(), + 0x0d => "PSBT_IN_HASH256".to_string(), + 0x0e => "PSBT_IN_PREVIOUS_TXID".to_string(), + 0x0f => "PSBT_IN_OUTPUT_INDEX".to_string(), + 0x10 => "PSBT_IN_SEQUENCE".to_string(), + 0x11 => "PSBT_IN_REQUIRED_TIME_LOCKTIME".to_string(), + 0x12 => "PSBT_IN_REQUIRED_HEIGHT_LOCKTIME".to_string(), + 0x13 => "PSBT_IN_TAP_KEY_SIG".to_string(), + 0x14 => "PSBT_IN_TAP_SCRIPT_SIG".to_string(), + 0x15 => "PSBT_IN_TAP_LEAF_SCRIPT".to_string(), + 0x16 => "PSBT_IN_TAP_BIP32_DERIVATION".to_string(), + 0x17 => "PSBT_IN_TAP_INTERNAL_KEY".to_string(), + 0x18 => "PSBT_IN_TAP_MERKLE_ROOT".to_string(), + 0x19 => "PSBT_IN_MUSIG2_PARTICIPANT_PUBKEYS".to_string(), + 0x1a => "PSBT_IN_MUSIG2_PUB_NONCE".to_string(), + 0x1b => "PSBT_IN_MUSIG2_PARTIAL_SIG".to_string(), + 0xFC => "PSBT_IN_PROPRIETARY".to_string(), + _ => format!("UNKNOWN_TYPE_0x{:02X}", type_id), + }, + PsbtMapContext::Output => match type_id { + 0x00 => "PSBT_OUT_REDEEM_SCRIPT".to_string(), + 0x01 => "PSBT_OUT_WITNESS_SCRIPT".to_string(), + 0x02 => "PSBT_OUT_BIP32_DERIVATION".to_string(), + 0x03 => "PSBT_OUT_AMOUNT".to_string(), + 0x04 => "PSBT_OUT_SCRIPT".to_string(), + 0x05 => "PSBT_OUT_TAP_INTERNAL_KEY".to_string(), + 0x06 => "PSBT_OUT_TAP_TREE".to_string(), + 0x07 => "PSBT_OUT_TAP_BIP32_DERIVATION".to_string(), + 0xFC => "PSBT_OUT_PROPRIETARY".to_string(), + _ => format!("UNKNOWN_TYPE_0x{:02X}", type_id), + }, + } +} + +/// Format a key for display +fn format_key(pair: &RawPair, context: PsbtMapContext) -> String { + let type_name = key_type_name(pair.type_value, context); + if pair.key.len() > 1 { + format!("{}[key_data={}]", type_name, hex::encode(&pair.key[1..])) + } else { + type_name + } +} + +/// Compare two maps and return differences +fn compare_maps( + left: &ParsedMap, + right: &ParsedMap, + context: PsbtMapContext, + prefix: &str, +) -> Vec { + let mut diffs = Vec::new(); + + // Build lookup by full key bytes for both maps + let left_by_key: HashMap<&[u8], &RawPair> = + left.pairs.iter().map(|p| (p.key.as_slice(), p)).collect(); + let right_by_key: HashMap<&[u8], &RawPair> = + right.pairs.iter().map(|p| (p.key.as_slice(), p)).collect(); + + // Check for keys in left but not in right, or with different values + for pair in &left.pairs { + let key_display = format_key(pair, context); + match right_by_key.get(pair.key.as_slice()) { + Some(right_pair) => { + if pair.value != right_pair.value { + diffs.push(format!( + "{} {} value differs:\n left: {}\n right: {}", + prefix, + key_display, + hex::encode(&pair.value), + hex::encode(&right_pair.value) + )); + } + } + None => { + diffs.push(format!( + "{} {} present in left but missing in right (value={})", + prefix, + key_display, + hex::encode(&pair.value) + )); + } + } + } + + // Check for keys in right but not in left + for pair in &right.pairs { + if !left_by_key.contains_key(pair.key.as_slice()) { + let key_display = format_key(pair, context); + diffs.push(format!( + "{} {} present in right but missing in left (value={})", + prefix, + key_display, + hex::encode(&pair.value) + )); + } + } + + diffs +} + +/// Compare two parsed PSBTs and return all differences +pub fn compare_psbts(left: &ParsedPsbt, right: &ParsedPsbt) -> Vec { + let mut diffs = Vec::new(); + + // Compare global maps + diffs.extend(compare_maps( + &left.global, + &right.global, + PsbtMapContext::Global, + "global:", + )); + + // Compare input counts + if left.inputs.len() != right.inputs.len() { + diffs.push(format!( + "Input count mismatch: left={}, right={}", + left.inputs.len(), + right.inputs.len() + )); + } + + // Compare each input + let input_count = std::cmp::min(left.inputs.len(), right.inputs.len()); + for i in 0..input_count { + diffs.extend(compare_maps( + &left.inputs[i], + &right.inputs[i], + PsbtMapContext::Input, + &format!("input[{}]:", i), + )); + } + + // Compare output counts + if left.outputs.len() != right.outputs.len() { + diffs.push(format!( + "Output count mismatch: left={}, right={}", + left.outputs.len(), + right.outputs.len() + )); + } + + // Compare each output + let output_count = std::cmp::min(left.outputs.len(), right.outputs.len()); + for i in 0..output_count { + diffs.extend(compare_maps( + &left.outputs[i], + &right.outputs[i], + PsbtMapContext::Output, + &format!("output[{}]:", i), + )); + } + + diffs +} + +/// Compare two PSBTs and return Ok(()) if equal, or Err with detailed differences +pub fn compare_psbt_bytes(left_bytes: &[u8], right_bytes: &[u8]) -> Result<(), String> { + let left = parse_psbt_to_maps(left_bytes)?; + let right = parse_psbt_to_maps(right_bytes)?; + + let diffs = compare_psbts(&left, &right); + + if diffs.is_empty() { + Ok(()) + } else { + Err(format!( + "PSBTs differ in {} place(s):\n{}", + diffs.len(), + diffs.join("\n") + )) + } +} + +/// Assert that two PSBT byte arrays are equal at the key-value pair level +/// +/// This provides much more detailed error messages than simple byte comparison, +/// showing exactly which fields differ between the two PSBTs. +/// +/// # Panics +/// +/// Panics if the PSBTs differ, with a detailed message showing which fields +/// are different. +pub fn assert_equal_psbt(left_bytes: &[u8], right_bytes: &[u8]) { + if let Err(e) = compare_psbt_bytes(left_bytes, right_bytes) { + panic!("{}", e); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_psbt_to_maps() { + use crate::fixed_script_wallet::test_utils::fixtures; + + let fixture = fixtures::load_psbt_fixture_with_format( + "bitcoin", + fixtures::SignatureState::Unsigned, + fixtures::TxFormat::Psbt, + ) + .expect("Failed to load fixture"); + + let psbt_bytes = fixture.to_psbt_bytes().expect("Failed to serialize PSBT"); + let parsed = parse_psbt_to_maps(&psbt_bytes).expect("Failed to parse PSBT"); + + // Should have global map with at least unsigned tx + assert!(parsed.global.has_type(0x00), "Should have unsigned tx"); + + // Should have inputs and outputs + assert!(!parsed.inputs.is_empty(), "Should have inputs"); + assert!(!parsed.outputs.is_empty(), "Should have outputs"); + } + + #[test] + fn test_compare_identical_psbts() { + use crate::fixed_script_wallet::test_utils::fixtures; + + let fixture = fixtures::load_psbt_fixture_with_format( + "bitcoin", + fixtures::SignatureState::Unsigned, + fixtures::TxFormat::Psbt, + ) + .expect("Failed to load fixture"); + + let psbt_bytes = fixture.to_psbt_bytes().expect("Failed to serialize PSBT"); + + // Comparing identical PSBTs should succeed + assert_equal_psbt(&psbt_bytes, &psbt_bytes); + } + + #[test] + fn test_compare_different_psbts() { + use crate::fixed_script_wallet::test_utils::fixtures; + + let unsigned = fixtures::load_psbt_fixture_with_format( + "bitcoin", + fixtures::SignatureState::Unsigned, + fixtures::TxFormat::Psbt, + ) + .expect("Failed to load unsigned fixture"); + + let fullsigned = fixtures::load_psbt_fixture_with_format( + "bitcoin", + fixtures::SignatureState::Fullsigned, + fixtures::TxFormat::Psbt, + ) + .expect("Failed to load fullsigned fixture"); + + let unsigned_bytes = unsigned.to_psbt_bytes().expect("Failed to serialize"); + let fullsigned_bytes = fullsigned.to_psbt_bytes().expect("Failed to serialize"); + + // Different PSBTs should produce an error + let result = compare_psbt_bytes(&unsigned_bytes, &fullsigned_bytes); + assert!(result.is_err(), "Different PSBTs should produce error"); + + let err = result.unwrap_err(); + assert!( + err.contains("differ"), + "Error should describe differences: {}", + err + ); + } +} diff --git a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/checksigverify.rs b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/checksigverify.rs index e74d273..5a456ad 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/checksigverify.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/checksigverify.rs @@ -30,6 +30,66 @@ pub fn build_p2tr_ns_script(keys: &[CompressedPublicKey]) -> ScriptBuf { builder.into_script() } +/// A resolved tap leaf with depth and the actual keys +struct TapLeaf { + depth: u8, + keys: [CompressedPublicKey; 2], +} + +/// Get the tap leaf configuration for a BitGo wallet. +/// +/// For p2trMusig2: 2 leaves at depth 1 +/// - user+backup +/// - backup+bitgo +/// +/// For p2trLegacy: 3 leaves +/// - user+bitgo at depth 1 +/// - user+backup at depth 2 +/// - backup+bitgo at depth 2 +fn get_tap_leaves(keys: &PubTriple, is_musig2: bool) -> Vec { + let [user, backup, bitgo] = *keys; + + if is_musig2 { + vec![ + TapLeaf { + depth: 1, + keys: [user, backup], + }, + TapLeaf { + depth: 1, + keys: [backup, bitgo], + }, + ] + } else { + vec![ + TapLeaf { + depth: 1, + keys: [user, bitgo], + }, + TapLeaf { + depth: 2, + keys: [user, backup], + }, + TapLeaf { + depth: 2, + keys: [backup, bitgo], + }, + ] + } +} + +/// Build a TaprootBuilder with all leaves added (but not finalized) +fn build_taproot_builder(keys: &PubTriple, is_musig2: bool) -> TaprootBuilder { + let mut builder = TaprootBuilder::new(); + + for leaf in get_tap_leaves(keys, is_musig2) { + let script = build_p2tr_ns_script(&leaf.keys); + builder = builder.add_leaf(leaf.depth, script).expect("valid leaf"); + } + + builder +} + fn build_p2tr_spend_info(keys: &PubTriple, p2tr_musig2: bool) -> TaprootSpendInfo { use super::bitgo_musig::key_agg_bitgo_p2tr_legacy; use super::bitgo_musig::key_agg_p2tr_musig2; @@ -37,9 +97,7 @@ fn build_p2tr_spend_info(keys: &PubTriple, p2tr_musig2: bool) -> TaprootSpendInf use crate::bitcoin::XOnlyPublicKey; let secp = Secp256k1::new(); - let user = keys[0]; - let backup = keys[1]; - let bitgo = keys[2]; + let [user, _backup, bitgo] = *keys; let agg_key_bytes = if p2tr_musig2 { key_agg_p2tr_musig2(&[user, bitgo]).expect("valid aggregation") @@ -48,32 +106,81 @@ fn build_p2tr_spend_info(keys: &PubTriple, p2tr_musig2: bool) -> TaprootSpendInf }; let internal_key = XOnlyPublicKey::from_slice(&agg_key_bytes).expect("valid xonly key"); - if p2tr_musig2 { - // Build taptree with 2 script paths: - // - user+backup (depth 1) - // - backup+bitgo (depth 1) - TaprootBuilder::new() - .add_leaf(1, build_p2tr_ns_script(&[user, backup])) - .expect("valid leaf") - .add_leaf(1, build_p2tr_ns_script(&[backup, bitgo])) - .expect("valid leaf") - .finalize(&secp, internal_key) - .expect("valid taptree") - } else { - // Build taptree with 3 script paths: - // - user+bitgo (depth 1) - // - user+backup (depth 2) - // - backup+bitgo (depth 2) - TaprootBuilder::new() - .add_leaf(1, build_p2tr_ns_script(&[user, bitgo])) - .expect("valid leaf") - .add_leaf(2, build_p2tr_ns_script(&[user, backup])) - .expect("valid leaf") - .add_leaf(2, build_p2tr_ns_script(&[backup, bitgo])) - .expect("valid leaf") - .finalize(&secp, internal_key) - .expect("valid taptree") + build_taproot_builder(keys, p2tr_musig2) + .finalize(&secp, internal_key) + .expect("valid taptree") +} + +/// Build a TapTree for PSBT output from wallet keys +pub fn build_tap_tree_for_output( + pub_triple: &PubTriple, + is_musig2: bool, +) -> miniscript::bitcoin::taproot::TapTree { + miniscript::bitcoin::taproot::TapTree::try_from(build_taproot_builder(pub_triple, is_musig2)) + .expect("valid tap tree") +} + +/// Create tap key origins for outputs with multiple leaf hashes per key. +/// Each key gets the leaf hashes for all leaves it participates in. +pub fn create_tap_bip32_derivation_for_output( + wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, + chain: u32, + index: u32, + pub_triple: &PubTriple, + is_musig2: bool, +) -> std::collections::BTreeMap< + miniscript::bitcoin::XOnlyPublicKey, + ( + Vec, + ( + miniscript::bitcoin::bip32::Fingerprint, + miniscript::bitcoin::bip32::DerivationPath, + ), + ), +> { + use crate::fixed_script_wallet::derivation_path; + use miniscript::bitcoin::secp256k1::{PublicKey, Secp256k1}; + use miniscript::bitcoin::taproot::{LeafVersion, TapLeafHash}; + use std::collections::BTreeMap; + + let secp = Secp256k1::new(); + + // Build leaf scripts and compute their hashes + let leaf_data: Vec<([CompressedPublicKey; 2], TapLeafHash)> = + get_tap_leaves(pub_triple, is_musig2) + .into_iter() + .map(|leaf| { + let script = build_p2tr_ns_script(&leaf.keys); + let hash = TapLeafHash::from_script(&script, LeafVersion::TapScript); + (leaf.keys, hash) + }) + .collect(); + + // For each key in the triple, collect leaf hashes of leaves it participates in + let mut map = BTreeMap::new(); + for (i, key) in pub_triple.iter().enumerate() { + let xpub = &wallet_keys.xpubs[i]; + let path = derivation_path(&wallet_keys.derivation_prefixes[i], chain, index); + let derived = xpub.derive_pub(&secp, &path).expect("valid derivation"); + let pubkey = PublicKey::from_slice(&derived.to_pub().to_bytes()).expect("valid public key"); + let (x_only, _parity) = pubkey.x_only_public_key(); + + // Collect leaf hashes for leaves this key participates in + let key_leaf_hashes: Vec = leaf_data + .iter() + .filter_map(|(leaf_keys, hash)| { + if leaf_keys.contains(key) { + Some(*hash) + } else { + None + } + }) + .collect(); + + map.insert(x_only, (key_leaf_hashes, (xpub.fingerprint(), path))); } + + map } #[derive(Debug)] diff --git a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs index 35b4af6..8d598ce 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/wallet_scripts/mod.rs @@ -9,7 +9,10 @@ pub use checkmultisig::{ build_multisig_script_2_of_3, parse_multisig_script_2_of_3, ScriptP2sh, ScriptP2shP2wsh, ScriptP2wsh, }; -pub use checksigverify::{build_p2tr_ns_script, ScriptP2tr}; +pub use checksigverify::{ + build_p2tr_ns_script, build_tap_tree_for_output, create_tap_bip32_derivation_for_output, + ScriptP2tr, +}; pub use singlesig::{build_p2pk_script, ScriptP2shP2pk}; use crate::address::networks::OutputScriptSupport; diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs index 40273cd..16dd68b 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs @@ -105,6 +105,245 @@ impl BitGoPsbt { }) } + /// Create an empty PSBT for the given network with wallet keys + /// + /// # Arguments + /// * `network` - Network name (utxolib or coin name) + /// * `wallet_keys` - The wallet's root keys (used to set global xpubs) + /// * `version` - Optional transaction version (default: 2) + /// * `lock_time` - Optional lock time (default: 0) + pub fn create_empty( + network: &str, + wallet_keys: &WasmRootWalletKeys, + version: Option, + lock_time: Option, + ) -> Result { + let network = parse_network(network)?; + let wallet_keys = wallet_keys.inner(); + + let psbt = crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::new( + network, + wallet_keys, + version, + lock_time, + ); + + Ok(BitGoPsbt { + psbt, + first_rounds: HashMap::new(), + }) + } + + /// Add an input to the PSBT + /// + /// # Arguments + /// * `txid` - The transaction ID (hex string) of the output being spent + /// * `vout` - The output index being spent + /// * `value` - The value in satoshis of the output being spent + /// * `script` - The output script (scriptPubKey) of the output being spent + /// * `sequence` - Optional sequence number (default: 0xFFFFFFFE for RBF) + /// + /// # Returns + /// The index of the newly added input + pub fn add_input( + &mut self, + txid: &str, + vout: u32, + value: u64, + script: &[u8], + sequence: Option, + prev_tx: Option>, + ) -> Result { + use miniscript::bitcoin::consensus::Decodable; + use miniscript::bitcoin::{ScriptBuf, Transaction, Txid}; + use std::str::FromStr; + + let txid = Txid::from_str(txid) + .map_err(|e| WasmUtxoError::new(&format!("Invalid txid: {}", e)))?; + let script = ScriptBuf::from_bytes(script.to_vec()); + + let prev_tx = prev_tx + .map(|bytes| { + Transaction::consensus_decode(&mut bytes.as_slice()) + .map_err(|e| WasmUtxoError::new(&format!("Invalid prev_tx: {}", e))) + }) + .transpose()?; + + Ok(self + .psbt + .add_input(txid, vout, value, script, sequence, prev_tx)) + } + + /// Add an output to the PSBT + /// + /// # Arguments + /// * `script` - The output script (scriptPubKey) + /// * `value` - The value in satoshis + /// + /// # Returns + /// The index of the newly added output + pub fn add_output(&mut self, script: &[u8], value: u64) -> Result { + use miniscript::bitcoin::ScriptBuf; + + let script = ScriptBuf::from_bytes(script.to_vec()); + + Ok(self.psbt.add_output(script, value)) + } + + /// Add a wallet input with full PSBT metadata + /// + /// This is a higher-level method that adds an input and populates all required + /// PSBT fields (scripts, derivation info, etc.) based on the wallet's chain type. + /// + /// # Arguments + /// * `txid` - The transaction ID (hex string) + /// * `vout` - The output index being spent + /// * `value` - The value in satoshis + /// * `chain` - The chain code (0/1=p2sh, 10/11=p2shP2wsh, 20/21=p2wsh, 30/31=p2tr, 40/41=p2trMusig2) + /// * `index` - The derivation index + /// * `wallet_keys` - The root wallet keys + /// * `signer` - The key that will sign ("user", "backup", or "bitgo") - required for p2tr/p2trMusig2 + /// * `cosigner` - The key that will co-sign - required for p2tr/p2trMusig2 + /// * `sequence` - Optional sequence number (default: 0xFFFFFFFE for RBF) + /// * `prev_tx` - Optional full previous transaction bytes (for non-segwit) + /// + /// # Returns + /// The index of the newly added input + #[allow(clippy::too_many_arguments)] + pub fn add_wallet_input( + &mut self, + txid: &str, + vout: u32, + value: u64, + wallet_keys: &WasmRootWalletKeys, + chain: u32, + index: u32, + signer: Option, + cosigner: Option, + sequence: Option, + prev_tx: Option>, + ) -> Result { + use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::{ + ScriptId, SignPath, SignerKey, + }; + use miniscript::bitcoin::Txid; + use std::str::FromStr; + + let txid = Txid::from_str(txid) + .map_err(|e| WasmUtxoError::new(&format!("Invalid txid: {}", e)))?; + + let wallet_keys = wallet_keys.inner(); + + let script_id = ScriptId { chain, index }; + let sign_path = match (signer.as_deref(), cosigner.as_deref()) { + (Some(signer_str), Some(cosigner_str)) => { + let signer: SignerKey = signer_str + .parse() + .map_err(|e: String| WasmUtxoError::new(&e))?; + let cosigner: SignerKey = cosigner_str + .parse() + .map_err(|e: String| WasmUtxoError::new(&e))?; + Some(SignPath { signer, cosigner }) + } + (None, None) => None, + _ => { + return Err(WasmUtxoError::new( + "Both signer and cosigner must be provided together or both omitted", + )) + } + }; + + use crate::fixed_script_wallet::bitgo_psbt::WalletInputOptions; + + self.psbt + .add_wallet_input( + txid, + vout, + value, + wallet_keys, + script_id, + WalletInputOptions { + sign_path, + sequence, + prev_tx: prev_tx.as_deref(), + }, + ) + .map_err(|e| WasmUtxoError::new(&e)) + } + + /// Add a wallet output with full PSBT metadata + /// + /// This creates a verifiable wallet output (typically for change) with all required + /// PSBT fields (scripts, derivation info) based on the wallet's chain type. + /// + /// # Arguments + /// * `chain` - The chain code (0/1=p2sh, 10/11=p2shP2wsh, 20/21=p2wsh, 30/31=p2tr, 40/41=p2trMusig2) + /// * `index` - The derivation index + /// * `value` - The value in satoshis + /// * `wallet_keys` - The root wallet keys + /// + /// # Returns + /// The index of the newly added output + pub fn add_wallet_output( + &mut self, + chain: u32, + index: u32, + value: u64, + wallet_keys: &WasmRootWalletKeys, + ) -> Result { + let wallet_keys = wallet_keys.inner(); + + self.psbt + .add_wallet_output(chain, index, value, wallet_keys) + .map_err(|e| WasmUtxoError::new(&e)) + } + + /// Add a replay protection input to the PSBT + /// + /// Replay protection inputs are P2SH-P2PK inputs used on forked networks to prevent + /// transaction replay attacks. They use a simple pubkey script without wallet derivation. + /// + /// # Arguments + /// * `ecpair` - The ECPair containing the public key for the replay protection input + /// * `txid` - The transaction ID (hex string) of the output being spent + /// * `vout` - The output index being spent + /// * `value` - The value in satoshis + /// * `sequence` - Optional sequence number (default: 0xFFFFFFFE for RBF) + /// + /// # Returns + /// The index of the newly added input + pub fn add_replay_protection_input( + &mut self, + ecpair: &WasmECPair, + txid: &str, + vout: u32, + value: u64, + sequence: Option, + ) -> Result { + use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::ReplayProtectionOptions; + use miniscript::bitcoin::{CompressedPublicKey, Txid}; + use std::str::FromStr; + + // Parse txid + let txid = Txid::from_str(txid) + .map_err(|e| WasmUtxoError::new(&format!("Invalid txid: {}", e)))?; + + // Get public key from ECPair and convert to CompressedPublicKey + let pubkey = ecpair.get_public_key(); + let compressed_pubkey = CompressedPublicKey::from_slice(&pubkey.serialize()) + .map_err(|e| WasmUtxoError::new(&format!("Failed to convert public key: {}", e)))?; + + let options = ReplayProtectionOptions { + sequence, + sighash_type: None, + prev_tx: None, + }; + + Ok(self + .psbt + .add_replay_protection_input(compressed_pubkey, txid, vout, value, options)) + } + /// Get the unsigned transaction ID pub fn unsigned_txid(&self) -> String { self.psbt.unsigned_txid().to_string() @@ -115,6 +354,16 @@ impl BitGoPsbt { self.psbt.network().to_string() } + /// Get the transaction version + pub fn version(&self) -> i32 { + self.psbt.psbt().unsigned_tx.version.0 + } + + /// Get the transaction lock time + pub fn lock_time(&self) -> u32 { + self.psbt.psbt().unsigned_tx.lock_time.to_consensus_u32() + } + /// Parse transaction with wallet keys to identify wallet inputs/outputs pub fn parse_transaction_with_wallet_keys( &self, 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 6c0d404..6d8f1d4 100644 --- a/packages/wasm-utxo/src/wasm/try_into_js_value.rs +++ b/packages/wasm-utxo/src/wasm/try_into_js_value.rs @@ -355,7 +355,8 @@ impl TryIntoJsValue for crate::fixed_script_wallet::bitgo_psbt::ParsedInput { "address" => self.address.clone(), "value" => self.value, "scriptId" => self.script_id, - "scriptType" => self.script_type + "scriptType" => self.script_type, + "sequence" => self.sequence ) } } diff --git a/packages/wasm-utxo/test/fixedScript/psbtReconstruction.ts b/packages/wasm-utxo/test/fixedScript/psbtReconstruction.ts new file mode 100644 index 0000000..dd6cc04 --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/psbtReconstruction.ts @@ -0,0 +1,206 @@ +import assert from "node:assert"; +import * as utxolib from "@bitgo/utxo-lib"; +import { fixedScriptWallet } from "../../js/index.js"; +import { + BitGoPsbt, + type InputScriptType, + type SignPath, +} from "../../js/fixedScriptWallet/index.js"; +import type { RootWalletKeys } from "../../js/fixedScriptWallet/RootWalletKeys.js"; +import { + loadPsbtFixture, + loadWalletKeysFromFixture, + loadReplayProtectionKeyFromFixture, + getPsbtBuffer, + type Fixture, +} from "./fixtureUtil.js"; +import { getFixtureNetworks } from "./networkSupport.util.js"; + +/** + * Infer signPath from scriptType (matches Rust logic) + */ +function getSignPathFromScriptType(scriptType: InputScriptType): SignPath | undefined { + switch (scriptType) { + case "p2trLegacy": + return { signer: "user", cosigner: "bitgo" }; + case "p2trMusig2ScriptPath": + return { signer: "user", cosigner: "backup" }; + case "p2trMusig2KeyPath": + return { signer: "user", cosigner: "bitgo" }; + default: + return undefined; + } +} + +/** + * Get "other wallet keys" for testing outputs from different wallet + * Uses the same seed as utxo-lib tests: "too many secrets" + */ +function getOtherWalletKeys(): RootWalletKeys { + const otherWalletKeys = utxolib.testutil.getKeyTriple("too many secrets"); + const neuteredKeys = otherWalletKeys.map((key) => key.neutered()) as [ + utxolib.BIP32Interface, + utxolib.BIP32Interface, + utxolib.BIP32Interface, + ]; + return fixedScriptWallet.RootWalletKeys.from({ + triple: neuteredKeys, + derivationPrefixes: ["0/0", "0/0", "0/0"], + }); +} + +/** + * Reverse a hex string by bytes (for txid conversion) + * Bitcoin txids in fixtures are in internal byte order (reversed) + */ +function reverseHex(hex: string): string { + return Buffer.from(hex, "hex").reverse().toString("hex"); +} + +describe("PSBT reconstruction", function () { + getFixtureNetworks().forEach((network) => { + const networkName = utxolib.getNetworkName(network); + + describe(`network: ${networkName}`, function () { + let fixture: Fixture; + let originalPsbt: BitGoPsbt; + let rootWalletKeys: RootWalletKeys; + let otherWalletKeys: RootWalletKeys; + + before(function () { + fixture = loadPsbtFixture(networkName, "unsigned"); + originalPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(getPsbtBuffer(fixture), networkName); + rootWalletKeys = loadWalletKeysFromFixture(fixture); + otherWalletKeys = getOtherWalletKeys(); + }); + + it("should reconstruct PSBT from parsed data with matching unsigned txid", function () { + // Parse the original PSBT to get inputs/outputs + const replayProtectionKey = loadReplayProtectionKeyFromFixture(fixture); + const parsedTx = originalPsbt.parseTransactionWithWalletKeys(rootWalletKeys, { + publicKeys: [replayProtectionKey], + }); + + // Parse outputs with other wallet keys to detect outputs from different wallet + const parsedOutputsOther = originalPsbt.parseOutputsWithWalletKeys(otherWalletKeys); + + // Create empty PSBT with same version/locktime + const reconstructed = BitGoPsbt.createEmpty(networkName, rootWalletKeys, { + version: originalPsbt.version, + lockTime: originalPsbt.lockTime, + }); + + // Add inputs + for (let i = 0; i < parsedTx.inputs.length; i++) { + const parsedInput = parsedTx.inputs[i]; + const fixtureInput = fixture.inputs[i]; + + // Convert fixture txid (internal byte order) to display order + const txid = reverseHex(fixtureInput.hash); + + if (parsedInput.scriptId !== null) { + // Wallet input - use addWalletInput + const signPath = getSignPathFromScriptType(parsedInput.scriptType); + + reconstructed.addWalletInput( + { + txid, + vout: fixtureInput.index, + value: parsedInput.value, + sequence: parsedInput.sequence, + }, + rootWalletKeys, + { scriptId: parsedInput.scriptId, signPath }, + ); + } else { + // Replay protection input - use the underived user key + assert.strictEqual( + parsedInput.scriptType, + "p2shP2pk", + `Non-wallet input ${i} should be p2shP2pk`, + ); + + reconstructed.addReplayProtectionInput( + { + txid, + vout: fixtureInput.index, + value: parsedInput.value, + sequence: parsedInput.sequence, + }, + replayProtectionKey, + ); + } + } + + // Add outputs + for (let i = 0; i < parsedTx.outputs.length; i++) { + const parsedOutput = parsedTx.outputs[i]; + const parsedOutputOther = parsedOutputsOther[i]; + + if (parsedOutput.scriptId !== null) { + // Output belongs to main wallet + reconstructed.addWalletOutput(rootWalletKeys, { + chain: parsedOutput.scriptId.chain, + index: parsedOutput.scriptId.index, + value: parsedOutput.value, + }); + } else if (parsedOutputOther.scriptId !== null) { + // Output belongs to other wallet (from seed "too many secrets") + reconstructed.addWalletOutput(otherWalletKeys, { + chain: parsedOutputOther.scriptId.chain, + index: parsedOutputOther.scriptId.index, + value: parsedOutputOther.value, + }); + } else { + // External output - use addOutput + reconstructed.addOutput({ + script: parsedOutput.script, + value: parsedOutput.value, + }); + } + } + + // Compare unsigned txids + assert.strictEqual( + reconstructed.unsignedTxid(), + originalPsbt.unsignedTxid(), + "Reconstructed PSBT should have same unsigned txid as original", + ); + }); + + it("should have correct version and lockTime getters", function () { + // Version and lockTime should be numbers + assert.strictEqual(typeof originalPsbt.version, "number", "version should be a number"); + assert.strictEqual(typeof originalPsbt.lockTime, "number", "lockTime should be a number"); + // Version should be 1 or 2 depending on network + assert.ok( + originalPsbt.version === 1 || originalPsbt.version === 2, + `version should be 1 or 2, got ${originalPsbt.version}`, + ); + // LockTime is typically 0 for these fixtures + assert.strictEqual(originalPsbt.lockTime, 0, "lockTime should be 0 for unsigned fixtures"); + }); + + it("should include sequence in parsed inputs", function () { + const replayProtectionKey = loadReplayProtectionKeyFromFixture(fixture); + const parsedTx = originalPsbt.parseTransactionWithWalletKeys(rootWalletKeys, { + publicKeys: [replayProtectionKey], + }); + + // Verify all inputs have sequence field + parsedTx.inputs.forEach((input, i) => { + assert.ok( + typeof input.sequence === "number", + `Input ${i} sequence should be a number, got ${typeof input.sequence}`, + ); + // Compare with fixture + assert.strictEqual( + input.sequence, + fixture.inputs[i].sequence, + `Input ${i} sequence should match fixture`, + ); + }); + }); + }); + }); +});