From a9a4ae08a3fd52fd77a4234c3ff54ca02f629e1a Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 3 Mar 2026 14:01:59 +0100 Subject: [PATCH 1/2] feat(wasm-utxo)!: add index-based PSBT/transaction mutation ops Add index-based insertion and removal methods to PSBT and transaction types, enabling flexible construction and editing workflows. ## Breaking Changes - All `add_*` methods now return `Result` consistently - `addInput`, `addOutput`, and related wallet methods may now throw errors for out-of-bounds indices - `inputCount`, `outputCount`, `lockTime`, and `version` changed from property accessors to methods (use `()` to call) - `unsignedTxid` renamed to `unsignedTxId` for consistency - `IPsbtIntrospection` renamed to `IPsbt` to reflect mutation support - `IPsbtIntrospectionWithAddress` renamed to `IPsbtWithAddress` ## New Features - `addInputAtIndex`/`addOutputAtIndex` for PSBTs and transactions - `addWalletInputAtIndex`/`addWalletOutputAtIndex` for wallet ops - `addReplayProtectionInputAtIndex` for replay protection - `removeInput`/`removeOutput` methods for PSBTs - New `psbt_ops` module with bounds-checked operations ## Implementation - All append operations delegate to index variants at `len()` - Bounds checking ensures indices are `<= len` (allow append) - Consistent error handling across all mutation operations - Methods maintain PSBT/transaction invariants during mutation Co-authored-by: llm-git Issue: BTC-3049 --- .../js/fixedScriptWallet/BitGoPsbt.ts | 184 +++++++++---- packages/wasm-utxo/js/index.ts | 19 +- packages/wasm-utxo/js/psbt.ts | 26 +- packages/wasm-utxo/js/transaction.ts | 14 +- .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 244 +++++++++--------- packages/wasm-utxo/src/lib.rs | 1 + packages/wasm-utxo/src/psbt_ops.rs | 58 +++++ .../src/wasm/fixed_script_wallet/mod.rs | 223 ++++++++++------ packages/wasm-utxo/src/wasm/psbt.rs | 52 +++- packages/wasm-utxo/src/wasm/transaction.rs | 76 ++++-- packages/wasm-utxo/test/bip322/index.ts | 8 +- .../test/fixedScript/finalizeExtract.ts | 8 +- .../parseTransactionWithWalletKeys.ts | 4 +- .../test/fixedScript/psbtReconstruction.ts | 46 ++-- 14 files changed, 628 insertions(+), 335 deletions(-) create mode 100644 packages/wasm-utxo/src/psbt_ops.rs diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index fae66000..084644a3 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -4,7 +4,7 @@ import { type PsbtOutputData, type PsbtOutputDataWithAddress, } from "../wasm/wasm_utxo.js"; -import type { IPsbtIntrospectionWithAddress } from "../psbt.js"; +import type { IPsbtWithAddress } from "../psbt.js"; import { type WalletKeysArg, RootWalletKeys } from "./RootWalletKeys.js"; import { type ReplayProtectionArg, ReplayProtection } from "./ReplayProtection.js"; import { type BIP32Arg, BIP32, isBIP32Arg } from "../bip32.js"; @@ -124,7 +124,7 @@ export type ParseOutputsOptions = { payGoPubkeys?: ECPairArg[]; }; -export class BitGoPsbt implements IPsbtIntrospectionWithAddress { +export class BitGoPsbt implements IPsbtWithAddress { protected constructor(protected _wasm: WasmBitGoPsbt) {} /** @@ -202,6 +202,45 @@ export class BitGoPsbt implements IPsbtIntrospectionWithAddress { * }, outputScript); * ``` */ + addInputAtIndex( + index: number, + txid: string, + vout: number, + value: bigint, + script: Uint8Array, + sequence?: number, + ): number; + addInputAtIndex(index: number, options: AddInputOptions, script: Uint8Array): number; + addInputAtIndex( + index: number, + txidOrOptions: string | AddInputOptions, + voutOrScript: number | Uint8Array, + value?: bigint, + script?: Uint8Array, + sequence?: number, + ): number { + if (typeof txidOrOptions === "string") { + return this._wasm.add_input_at_index( + index, + txidOrOptions, + voutOrScript as number, + value, + script, + sequence, + ); + } + const options = txidOrOptions; + return this._wasm.add_input_at_index( + index, + options.txid, + options.vout, + options.value, + voutOrScript as Uint8Array, + options.sequence, + options.prevTx, + ); + } + addInput(options: AddInputOptions, script: Uint8Array): number { return this._wasm.add_input( options.txid, @@ -225,41 +264,36 @@ export class BitGoPsbt implements IPsbtIntrospectionWithAddress { * const outputIndex = psbt.addOutput(outputScript, 50000n); * ``` */ + addOutputAtIndex(index: number, script: Uint8Array, value: bigint): number; + addOutputAtIndex(index: number, address: string, value: bigint): number; + addOutputAtIndex(index: number, options: AddOutputOptions): number; + addOutputAtIndex( + index: number, + scriptOrOptions: Uint8Array | string | AddOutputOptions, + value?: bigint, + ): number { + if (scriptOrOptions instanceof Uint8Array || typeof scriptOrOptions === "string") { + if (value === undefined) { + throw new Error("Value is required when passing a script or address"); + } + if (scriptOrOptions instanceof Uint8Array) { + return this._wasm.add_output_at_index(index, scriptOrOptions, value); + } + return this._wasm.add_output_with_address_at_index(index, scriptOrOptions, value); + } + + const options = scriptOrOptions; + if ("script" in options) { + return this._wasm.add_output_at_index(index, options.script, options.value); + } + if ("address" in options) { + return this._wasm.add_output_with_address_at_index(index, options.address, options.value); + } + throw new Error("Invalid output options"); + } + addOutput(script: Uint8Array, value: bigint): number; - /** - * Add an output to the PSBT by address - * - * @param address - The destination address - * @param value - Value in satoshis - * @returns The index of the newly added output - * - * @example - * ```typescript - * const outputIndex = psbt.addOutput("bc1q...", 50000n); - * ``` - */ addOutput(address: string, value: bigint): number; - /** - * Add an output to the PSBT - * - * @param options - Output options (script or address, and value) - * @returns The index of the newly added output - * - * @example - * ```typescript - * // Using script - * const outputIndex = psbt.addOutput({ - * script: outputScript, - * value: 50000n, - * }); - * - * // Using address - * const outputIndex = psbt.addOutput({ - * address: "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", - * value: 50000n, - * }); - * ``` - */ addOutput(options: AddOutputOptions): number; addOutput(scriptOrOptions: Uint8Array | string | AddOutputOptions, value?: bigint): number { if (scriptOrOptions instanceof Uint8Array || typeof scriptOrOptions === "string") { @@ -321,6 +355,28 @@ export class BitGoPsbt implements IPsbtIntrospectionWithAddress { * ); * ``` */ + addWalletInputAtIndex( + index: number, + inputOptions: AddInputOptions, + walletKeys: WalletKeysArg, + walletOptions: AddWalletInputOptions, + ): number { + const keys = RootWalletKeys.from(walletKeys); + return this._wasm.add_wallet_input_at_index( + index, + inputOptions.txid, + inputOptions.vout, + inputOptions.value, + keys.wasm, + walletOptions.scriptId.chain, + walletOptions.scriptId.index, + walletOptions.signPath?.signer, + walletOptions.signPath?.cosigner, + inputOptions.sequence, + inputOptions.prevTx, + ); + } + addWalletInput( inputOptions: AddInputOptions, walletKeys: WalletKeysArg, @@ -371,6 +427,21 @@ export class BitGoPsbt implements IPsbtIntrospectionWithAddress { * }); * ``` */ + addWalletOutputAtIndex( + index: number, + walletKeys: WalletKeysArg, + options: AddWalletOutputOptions, + ): number { + const keys = RootWalletKeys.from(walletKeys); + return this._wasm.add_wallet_output_at_index( + index, + options.chain, + options.index, + options.value, + keys.wasm, + ); + } + 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); @@ -395,6 +466,23 @@ export class BitGoPsbt implements IPsbtIntrospectionWithAddress { * ); * ``` */ + addReplayProtectionInputAtIndex( + index: number, + inputOptions: AddInputOptions, + key: ECPairArg, + ): number { + const ecpair = ECPair.from(key); + return this._wasm.add_replay_protection_input_at_index( + index, + ecpair.wasm, + inputOptions.txid, + inputOptions.vout, + inputOptions.value, + inputOptions.sequence, + inputOptions.prevTx, + ); + } + addReplayProtectionInput(inputOptions: AddInputOptions, key: ECPairArg): number { const ecpair = ECPair.from(key); return this._wasm.add_replay_protection_input( @@ -407,11 +495,19 @@ export class BitGoPsbt implements IPsbtIntrospectionWithAddress { ); } + removeInput(index: number): void { + this._wasm.remove_input(index); + } + + removeOutput(index: number): void { + this._wasm.remove_output(index); + } + /** * Get the unsigned transaction ID * @returns The unsigned transaction ID */ - unsignedTxid(): string { + unsignedTxId(): string { return this._wasm.unsigned_txid(); } @@ -419,15 +515,11 @@ export class BitGoPsbt implements IPsbtIntrospectionWithAddress { * Get the transaction version * @returns The transaction version number */ - get version(): number { + version(): number { return this._wasm.version(); } - /** - * Get the transaction lock time - * @returns The transaction lock time - */ - get lockTime(): number { + lockTime(): number { return this._wasm.lock_time(); } @@ -828,15 +920,11 @@ export class BitGoPsbt implements IPsbtIntrospectionWithAddress { * Get the number of inputs in the PSBT * @returns The number of inputs */ - get inputCount(): number { + inputCount(): number { return this._wasm.input_count(); } - /** - * Get the number of outputs in the PSBT - * @returns The number of outputs - */ - get outputCount(): number { + outputCount(): number { return this._wasm.output_count(); } diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index 85d135c3..8c41079a 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -119,6 +119,19 @@ declare module "./wasm/wasm_utxo.js" { // Extraction methods extractTransaction(): WasmTransaction; + // Mutation methods + addInputAtIndex( + index: number, + txid: string, + vout: number, + value: bigint, + script: Uint8Array, + sequence?: number, + ): number; + addOutputAtIndex(index: number, script: Uint8Array, value: bigint): number; + removeInput(index: number): void; + removeOutput(index: number): void; + // Metadata methods unsignedTxId(): string; lockTime(): number; @@ -130,8 +143,4 @@ export { WrapDescriptor as Descriptor } from "./wasm/wasm_utxo.js"; export { WrapMiniscript as Miniscript } from "./wasm/wasm_utxo.js"; export { WrapPsbt as Psbt } from "./wasm/wasm_utxo.js"; export { DashTransaction, Transaction, ZcashTransaction } from "./transaction.js"; -export { - hasPsbtMagic, - type IPsbtIntrospection, - type IPsbtIntrospectionWithAddress, -} from "./psbt.js"; +export { hasPsbtMagic, type IPsbt, type IPsbtWithAddress } from "./psbt.js"; diff --git a/packages/wasm-utxo/js/psbt.ts b/packages/wasm-utxo/js/psbt.ts index 005bae59..a8067a54 100644 --- a/packages/wasm-utxo/js/psbt.ts +++ b/packages/wasm-utxo/js/psbt.ts @@ -1,15 +1,29 @@ import type { PsbtInputData, PsbtOutputData, PsbtOutputDataWithAddress } from "./wasm/wasm_utxo.js"; -/** Common interface for PSBT introspection methods */ -export interface IPsbtIntrospection { - readonly inputCount: number; - readonly outputCount: number; +/** Common interface for PSBT types */ +export interface IPsbt { + inputCount(): number; + outputCount(): number; getInputs(): PsbtInputData[]; getOutputs(): PsbtOutputData[]; + version(): number; + lockTime(): number; + unsignedTxId(): string; + addInputAtIndex( + index: number, + txid: string, + vout: number, + value: bigint, + script: Uint8Array, + sequence?: number, + ): number; + addOutputAtIndex(index: number, script: Uint8Array, value: bigint): number; + removeInput(index: number): void; + removeOutput(index: number): void; } -/** Extended introspection with address resolution (no coin parameter needed) */ -export interface IPsbtIntrospectionWithAddress extends IPsbtIntrospection { +/** Extended PSBT with address resolution (no coin parameter needed) */ +export interface IPsbtWithAddress extends IPsbt { getOutputsWithAddress(): PsbtOutputDataWithAddress[]; } diff --git a/packages/wasm-utxo/js/transaction.ts b/packages/wasm-utxo/js/transaction.ts index d1529d9f..435d54aa 100644 --- a/packages/wasm-utxo/js/transaction.ts +++ b/packages/wasm-utxo/js/transaction.ts @@ -41,16 +41,18 @@ export class Transaction implements ITransaction { * @param sequence - Optional sequence number (default: 0xFFFFFFFF) * @returns The index of the newly added input */ + addInputAtIndex(index: number, txid: string, vout: number, sequence?: number): number { + return this._wasm.add_input_at_index(index, txid, vout, sequence); + } + addInput(txid: string, vout: number, sequence?: number): number { return this._wasm.add_input(txid, vout, sequence); } - /** - * Add an output to the transaction - * @param script - Output script (scriptPubKey) - * @param value - Value in satoshis - * @returns The index of the newly added output - */ + addOutputAtIndex(index: number, script: Uint8Array, value: bigint): number { + return this._wasm.add_output_at_index(index, script, value); + } + addOutput(script: Uint8Array, value: bigint): number { return this._wasm.add_output(script, value); } 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 82a4e9da..3e46924e 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 @@ -572,28 +572,25 @@ impl BitGoPsbt { /// /// # Returns /// The index of the newly added input - pub fn add_input( + #[allow(clippy::too_many_arguments)] + pub fn add_input_at_index( &mut self, + index: usize, txid: Txid, vout: u32, value: u64, script: miniscript::bitcoin::ScriptBuf, sequence: Option, prev_tx: Option, - ) -> usize { + ) -> Result { 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), @@ -603,11 +600,21 @@ impl BitGoPsbt { ..Default::default() }; - // Add to the PSBT - psbt.unsigned_tx.input.push(tx_in); - psbt.inputs.push(psbt_input); + crate::psbt_ops::insert_input(self.psbt_mut(), index, tx_in, psbt_input) + } - psbt.inputs.len() - 1 + pub fn add_input( + &mut self, + txid: Txid, + vout: u32, + value: u64, + script: miniscript::bitcoin::ScriptBuf, + sequence: Option, + prev_tx: Option, + ) -> usize { + let index = self.psbt().inputs.len(); + self.add_input_at_index(index, txid, vout, value, script, sequence, prev_tx) + .expect("insert at len should never fail") } /// Add a replay protection input (p2shP2pk) to the PSBT @@ -624,14 +631,15 @@ impl BitGoPsbt { /// /// # Returns /// The index of the newly added input - pub fn add_replay_protection_input( + pub fn add_replay_protection_input_at_index( &mut self, + index: usize, pubkey: miniscript::bitcoin::CompressedPublicKey, txid: Txid, vout: u32, value: u64, options: ReplayProtectionOptions, - ) -> usize { + ) -> Result { use crate::fixed_script_wallet::wallet_scripts::ScriptP2shP2pk; use miniscript::bitcoin::consensus::Decodable; use miniscript::bitcoin::psbt::{Input, PsbtSighashType}; @@ -640,14 +648,11 @@ impl BitGoPsbt { }; 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(), @@ -655,28 +660,23 @@ impl BitGoPsbt { 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() { + 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 - } - }); + | Network::BitcoinGold => PsbtSighashType::from_u32(0x41), + _ => PsbtSighashType::from_u32(0x01), + }); - // 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"); @@ -688,11 +688,20 @@ impl BitGoPsbt { }); } - // Add to the PSBT - psbt.unsigned_tx.input.push(tx_in); - psbt.inputs.push(psbt_input); + crate::psbt_ops::insert_input(self.psbt_mut(), index, tx_in, psbt_input) + } - psbt.inputs.len() - 1 + pub fn add_replay_protection_input( + &mut self, + pubkey: miniscript::bitcoin::CompressedPublicKey, + txid: Txid, + vout: u32, + value: u64, + options: ReplayProtectionOptions, + ) -> usize { + let index = self.psbt().inputs.len(); + self.add_replay_protection_input_at_index(index, pubkey, txid, vout, value, options) + .expect("insert at len should never fail") } /// Add an output to the PSBT @@ -703,32 +712,48 @@ impl BitGoPsbt { /// /// # Returns /// The index of the newly added output - pub fn add_output(&mut self, script: miniscript::bitcoin::ScriptBuf, value: u64) -> usize { + pub fn add_output_at_index( + &mut self, + index: usize, + script: miniscript::bitcoin::ScriptBuf, + value: u64, + ) -> Result { 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); + crate::psbt_ops::insert_output( + self.psbt_mut(), + index, + tx_out, + miniscript::bitcoin::psbt::Output::default(), + ) + } - psbt.outputs.len() - 1 + pub fn add_output(&mut self, script: miniscript::bitcoin::ScriptBuf, value: u64) -> usize { + let index = self.psbt().outputs.len(); + self.add_output_at_index(index, script, value) + .expect("insert at len should never fail") } - pub fn add_output_with_address(&mut self, address: &str, value: u64) -> Result { + pub fn add_output_with_address_at_index( + &mut self, + index: usize, + address: &str, + value: u64, + ) -> Result { let script = crate::address::networks::to_output_script_with_network(address, self.network()) .map_err(|e| e.to_string())?; - Ok(self.add_output(script, value)) + self.add_output_at_index(index, script, value) + } + + pub fn add_output_with_address(&mut self, address: &str, value: u64) -> Result { + let index = self.psbt().outputs.len(); + self.add_output_with_address_at_index(index, address, value) } /// Add a wallet input with full PSBT metadata @@ -746,8 +771,10 @@ impl BitGoPsbt { /// /// # Returns /// The index of the newly added input - pub fn add_wallet_input( + #[allow(clippy::too_many_arguments)] + pub fn add_wallet_input_at_index( &mut self, + index: usize, txid: Txid, vout: u32, value: u64, @@ -767,26 +794,21 @@ impl BitGoPsbt { let psbt = self.psbt_mut(); let chain = script_id.chain; - let index = script_id.index; + let derivation_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) + .derive_for_chain_and_index(chain, derivation_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(), @@ -794,53 +816,43 @@ impl BitGoPsbt { witness: miniscript::bitcoin::Witness::default(), }; - // Create the PSBT input let mut psbt_input = Input::default(); - // Determine if segwit based on chain type (all types except P2sh are segwit) let is_segwit = chain_enum.script_type != OutputScriptType::P2sh; 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.bip32_derivation = + create_bip32_derivation(wallet_keys, chain, derivation_index); 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.bip32_derivation = + create_bip32_derivation(wallet_keys, chain, derivation_index); 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.bip32_derivation = + create_bip32_derivation(wallet_keys, chain, derivation_index); 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() })?; @@ -851,8 +863,6 @@ impl BitGoPsbt { 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( @@ -860,7 +870,6 @@ impl BitGoPsbt { ); 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)) @@ -868,45 +877,36 @@ impl BitGoPsbt { "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, + derivation_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, + derivation_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 + let user_key = pub_triple[0]; + let bitgo_key = pub_triple[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, @@ -914,18 +914,26 @@ impl BitGoPsbt { 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); + crate::psbt_ops::insert_input(psbt, index, tx_in, psbt_input) + } - Ok(psbt.inputs.len() - 1) + 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 { + let index = self.psbt().inputs.len(); + self.add_wallet_input_at_index(index, txid, vout, value, wallet_keys, script_id, options) } /// Add a wallet output with full PSBT metadata @@ -941,10 +949,12 @@ impl BitGoPsbt { /// /// # Returns /// The index of the newly added output - pub fn add_wallet_output( + #[allow(clippy::too_many_arguments)] + pub fn add_wallet_output_at_index( &mut self, + index: usize, chain: u32, - index: u32, + derivation_index: u32, value: u64, wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, ) -> Result { @@ -959,79 +969,81 @@ impl BitGoPsbt { 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) + .derive_for_chain_and_index(chain, derivation_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.bip32_derivation = + create_bip32_derivation(wallet_keys, chain, derivation_index); 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.bip32_derivation = + create_bip32_derivation(wallet_keys, chain, derivation_index); 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.bip32_derivation = + create_bip32_derivation(wallet_keys, chain, derivation_index); 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, + derivation_index, &pub_triple, is_musig2, ); } } - // Add to PSBT - psbt.unsigned_tx.output.push(tx_out); - psbt.outputs.push(psbt_output); + crate::psbt_ops::insert_output(psbt, index, tx_out, psbt_output) + } + + pub fn add_wallet_output( + &mut self, + chain: u32, + index: u32, + value: u64, + wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, + ) -> Result { + let insert_index = self.psbt().outputs.len(); + self.add_wallet_output_at_index(insert_index, chain, index, value, wallet_keys) + } + + pub fn remove_input(&mut self, index: usize) -> Result<(), String> { + crate::psbt_ops::remove_input(self.psbt_mut(), index) + } - Ok(psbt.outputs.len() - 1) + pub fn remove_output(&mut self, index: usize) -> Result<(), String> { + crate::psbt_ops::remove_output(self.psbt_mut(), index) } pub fn network(&self) -> Network { diff --git a/packages/wasm-utxo/src/lib.rs b/packages/wasm-utxo/src/lib.rs index 046d010b..096a4715 100644 --- a/packages/wasm-utxo/src/lib.rs +++ b/packages/wasm-utxo/src/lib.rs @@ -9,6 +9,7 @@ pub mod inspect; pub mod message; mod networks; pub mod paygo; +pub mod psbt_ops; #[cfg(test)] mod test_utils; pub mod zcash; diff --git a/packages/wasm-utxo/src/psbt_ops.rs b/packages/wasm-utxo/src/psbt_ops.rs new file mode 100644 index 00000000..8259c7a7 --- /dev/null +++ b/packages/wasm-utxo/src/psbt_ops.rs @@ -0,0 +1,58 @@ +use miniscript::bitcoin::{psbt, Psbt, TxIn, TxOut}; + +fn check_bounds(index: usize, len: usize, name: &str) -> Result<(), String> { + if index > len { + return Err(format!( + "{name} index {index} out of bounds (have {len} {name}s)" + )); + } + Ok(()) +} + +pub fn insert_input( + psbt: &mut Psbt, + index: usize, + tx_in: TxIn, + psbt_input: psbt::Input, +) -> Result { + check_bounds(index, psbt.inputs.len(), "input")?; + psbt.unsigned_tx.input.insert(index, tx_in); + psbt.inputs.insert(index, psbt_input); + Ok(index) +} + +pub fn insert_output( + psbt: &mut Psbt, + index: usize, + tx_out: TxOut, + psbt_output: psbt::Output, +) -> Result { + check_bounds(index, psbt.outputs.len(), "output")?; + psbt.unsigned_tx.output.insert(index, tx_out); + psbt.outputs.insert(index, psbt_output); + Ok(index) +} + +pub fn remove_input(psbt: &mut Psbt, index: usize) -> Result<(), String> { + if index >= psbt.inputs.len() { + return Err(format!( + "input index {index} out of bounds (have {} inputs)", + psbt.inputs.len() + )); + } + psbt.unsigned_tx.input.remove(index); + psbt.inputs.remove(index); + Ok(()) +} + +pub fn remove_output(psbt: &mut Psbt, index: usize) -> Result<(), String> { + if index >= psbt.outputs.len() { + return Err(format!( + "output index {index} out of bounds (have {} outputs)", + psbt.outputs.len() + )); + } + psbt.unsigned_tx.output.remove(index); + psbt.outputs.remove(index); + Ok(()) +} diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs index 168d99bd..2e280179 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -376,8 +376,10 @@ impl BitGoPsbt { /// /// # Returns /// The index of the newly added input - pub fn add_input( + #[allow(clippy::too_many_arguments)] + pub fn add_input_at_index( &mut self, + index: usize, txid: &str, vout: u32, value: u64, @@ -392,7 +394,6 @@ impl BitGoPsbt { 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()) @@ -400,35 +401,53 @@ impl BitGoPsbt { }) .transpose()?; - Ok(self - .psbt - .add_input(txid, vout, value, script, sequence, prev_tx)) + self.psbt + .add_input_at_index(index, txid, vout, value, script, sequence, prev_tx) + .map_err(|e| WasmUtxoError::new(&e)) } - /// 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; + pub fn add_input( + &mut self, + txid: &str, + vout: u32, + value: u64, + script: &[u8], + sequence: Option, + prev_tx: Option>, + ) -> Result { + let index = self.psbt.psbt().inputs.len(); + self.add_input_at_index(index, txid, vout, value, script, sequence, prev_tx) + } + pub fn add_output_at_index( + &mut self, + index: usize, + script: &[u8], + value: u64, + ) -> Result { + use miniscript::bitcoin::ScriptBuf; let script = ScriptBuf::from_bytes(script.to_vec()); + self.psbt + .add_output_at_index(index, script, value) + .map_err(|e| WasmUtxoError::new(&e)) + } - Ok(self.psbt.add_output(script, value)) + pub fn add_output(&mut self, script: &[u8], value: u64) -> Result { + let index = self.psbt.psbt().outputs.len(); + self.add_output_at_index(index, script, value) + } + + pub fn add_output_with_address_at_index( + &mut self, + index: usize, + address: &str, + value: u64, + ) -> Result { + Ok(self + .psbt + .add_output_with_address_at_index(index, address, value)?) } - /// Add an output to the PSBT by address - /// - /// # Arguments - /// * `address` - The destination address - /// * `value` - The value in satoshis - /// - /// # Returns - /// The index of the newly added output pub fn add_output_with_address( &mut self, address: &str, @@ -437,34 +456,28 @@ impl BitGoPsbt { Ok(self.psbt.add_output_with_address(address, 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 + pub fn remove_input(&mut self, index: usize) -> Result<(), WasmUtxoError> { + self.psbt + .remove_input(index) + .map_err(|e| WasmUtxoError::new(&e)) + } + + pub fn remove_output(&mut self, index: usize) -> Result<(), WasmUtxoError> { + self.psbt + .remove_output(index) + .map_err(|e| WasmUtxoError::new(&e)) + } + #[allow(clippy::too_many_arguments)] - pub fn add_wallet_input( + pub fn add_wallet_input_at_index( &mut self, + index: usize, txid: &str, vout: u32, value: u64, wallet_keys: &WasmRootWalletKeys, chain: u32, - index: u32, + derivation_index: u32, signer: Option, cosigner: Option, sequence: Option, @@ -473,6 +486,7 @@ impl BitGoPsbt { use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::{ ScriptId, SignPath, SignerKey, }; + use crate::fixed_script_wallet::bitgo_psbt::WalletInputOptions; use miniscript::bitcoin::Txid; use std::str::FromStr; @@ -480,8 +494,10 @@ impl BitGoPsbt { .map_err(|e| WasmUtxoError::new(&format!("Invalid txid: {}", e)))?; let wallet_keys = wallet_keys.inner(); - - let script_id = ScriptId { chain, index }; + let script_id = ScriptId { + chain, + index: derivation_index, + }; let sign_path = match (signer.as_deref(), cosigner.as_deref()) { (Some(signer_str), Some(cosigner_str)) => { let signer: SignerKey = signer_str @@ -500,10 +516,9 @@ impl BitGoPsbt { } }; - use crate::fixed_script_wallet::bitgo_psbt::WalletInputOptions; - self.psbt - .add_wallet_input( + .add_wallet_input_at_index( + index, txid, vout, value, @@ -518,49 +533,65 @@ impl BitGoPsbt { .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( + #[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 { + let insert_index = self.psbt.psbt().inputs.len(); + self.add_wallet_input_at_index( + insert_index, + txid, + vout, + value, + wallet_keys, + chain, + index, + signer, + cosigner, + sequence, + prev_tx, + ) + } + + pub fn add_wallet_output_at_index( + &mut self, + index: usize, + chain: u32, + derivation_index: u32, value: u64, wallet_keys: &WasmRootWalletKeys, ) -> Result { let wallet_keys = wallet_keys.inner(); - self.psbt - .add_wallet_output(chain, index, value, wallet_keys) + .add_wallet_output_at_index(index, chain, derivation_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( + pub fn add_wallet_output( &mut self, + chain: u32, + index: u32, + value: u64, + wallet_keys: &WasmRootWalletKeys, + ) -> Result { + let insert_index = self.psbt.psbt().outputs.len(); + self.add_wallet_output_at_index(insert_index, chain, index, value, wallet_keys) + } + + #[allow(clippy::too_many_arguments)] + pub fn add_replay_protection_input_at_index( + &mut self, + index: usize, ecpair: &WasmECPair, txid: &str, vout: u32, @@ -572,11 +603,9 @@ impl BitGoPsbt { 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)))?; @@ -587,9 +616,31 @@ impl BitGoPsbt { prev_tx: prev_tx.as_deref(), }; - Ok(self - .psbt - .add_replay_protection_input(compressed_pubkey, txid, vout, value, options)) + self.psbt + .add_replay_protection_input_at_index( + index, + compressed_pubkey, + txid, + vout, + value, + options, + ) + .map_err(|e| WasmUtxoError::new(&e)) + } + + pub fn add_replay_protection_input( + &mut self, + ecpair: &WasmECPair, + txid: &str, + vout: u32, + value: u64, + sequence: Option, + prev_tx: Option>, + ) -> Result { + let index = self.psbt.psbt().inputs.len(); + self.add_replay_protection_input_at_index( + index, ecpair, txid, vout, value, sequence, prev_tx, + ) } /// Get the unsigned transaction ID diff --git a/packages/wasm-utxo/src/wasm/psbt.rs b/packages/wasm-utxo/src/wasm/psbt.rs index e2f44bec..862c6259 100644 --- a/packages/wasm-utxo/src/wasm/psbt.rs +++ b/packages/wasm-utxo/src/wasm/psbt.rs @@ -288,9 +288,10 @@ impl WrapPsbt { /// /// # Returns /// The index of the newly added input - #[wasm_bindgen(js_name = addInput)] - pub fn add_input( + #[wasm_bindgen(js_name = addInputAtIndex)] + pub fn add_input_at_index( &mut self, + index: usize, txid: &str, vout: u32, value: u64, @@ -307,7 +308,6 @@ impl WrapPsbt { sequence: Sequence(sequence.unwrap_or(0xFFFFFFFE)), witness: miniscript::bitcoin::Witness::default(), }; - let psbt_input = psbt::Input { witness_utxo: Some(TxOut { value: Amount::from_sat(value), @@ -316,10 +316,20 @@ impl WrapPsbt { ..Default::default() }; - self.0.unsigned_tx.input.push(tx_in); - self.0.inputs.push(psbt_input); + crate::psbt_ops::insert_input(&mut self.0, index, tx_in, psbt_input) + .map_err(|e| JsError::new(&e)) + } - Ok(self.0.inputs.len() - 1) + #[wasm_bindgen(js_name = addInput)] + pub fn add_input( + &mut self, + txid: &str, + vout: u32, + value: u64, + script: &[u8], + sequence: Option, + ) -> Result { + self.add_input_at_index(self.0.inputs.len(), txid, vout, value, script, sequence) } /// Add an output to the PSBT @@ -330,21 +340,37 @@ impl WrapPsbt { /// /// # Returns /// The index of the newly added output - #[wasm_bindgen(js_name = addOutput)] - pub fn add_output(&mut self, script: &[u8], value: u64) -> usize { + #[wasm_bindgen(js_name = addOutputAtIndex)] + pub fn add_output_at_index( + &mut self, + index: usize, + script: &[u8], + value: u64, + ) -> Result { let script = ScriptBuf::from_bytes(script.to_vec()); - let tx_out = TxOut { value: Amount::from_sat(value), script_pubkey: script, }; - let psbt_output = psbt::Output::default(); + crate::psbt_ops::insert_output(&mut self.0, index, tx_out, psbt::Output::default()) + .map_err(|e| JsError::new(&e)) + } + + #[wasm_bindgen(js_name = addOutput)] + pub fn add_output(&mut self, script: &[u8], value: u64) -> usize { + self.add_output_at_index(self.0.outputs.len(), script, value) + .expect("insert at len should never fail") + } - self.0.unsigned_tx.output.push(tx_out); - self.0.outputs.push(psbt_output); + #[wasm_bindgen(js_name = removeInput)] + pub fn remove_input(&mut self, index: usize) -> Result<(), JsError> { + crate::psbt_ops::remove_input(&mut self.0, index).map_err(|e| JsError::new(&e)) + } - self.0.outputs.len() - 1 + #[wasm_bindgen(js_name = removeOutput)] + pub fn remove_output(&mut self, index: usize) -> Result<(), JsError> { + crate::psbt_ops::remove_output(&mut self.0, index).map_err(|e| JsError::new(&e)) } /// Get the unsigned transaction bytes diff --git a/packages/wasm-utxo/src/wasm/transaction.rs b/packages/wasm-utxo/src/wasm/transaction.rs index dbe2516d..2e752d76 100644 --- a/packages/wasm-utxo/src/wasm/transaction.rs +++ b/packages/wasm-utxo/src/wasm/transaction.rs @@ -43,40 +43,72 @@ impl WasmTransaction { /// /// # Returns /// The index of the newly added input - pub fn add_input( + pub fn add_input_at_index( &mut self, + index: usize, txid: &str, vout: u32, sequence: Option, ) -> Result { use miniscript::bitcoin::{transaction::Sequence, OutPoint, ScriptBuf, TxIn, Txid}; use std::str::FromStr; + if index > self.tx.input.len() { + return Err(WasmUtxoError::new(&format!( + "Input index {} out of bounds (have {} inputs)", + index, + self.tx.input.len() + ))); + } let txid = Txid::from_str(txid) .map_err(|e| WasmUtxoError::new(&format!("Invalid txid: {}", e)))?; - self.tx.input.push(TxIn { - previous_output: OutPoint { txid, vout }, - script_sig: ScriptBuf::new(), - sequence: sequence.map(Sequence).unwrap_or(Sequence::MAX), - witness: Default::default(), - }); - Ok(self.tx.input.len() - 1) + self.tx.input.insert( + index, + TxIn { + previous_output: OutPoint { txid, vout }, + script_sig: ScriptBuf::new(), + sequence: sequence.map(Sequence).unwrap_or(Sequence::MAX), + witness: Default::default(), + }, + ); + Ok(index) } - /// Add an output to the transaction - /// - /// # 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) -> usize { + pub fn add_input( + &mut self, + txid: &str, + vout: u32, + sequence: Option, + ) -> Result { + self.add_input_at_index(self.tx.input.len(), txid, vout, sequence) + } + + pub fn add_output_at_index( + &mut self, + index: usize, + script: &[u8], + value: u64, + ) -> Result { use miniscript::bitcoin::{Amount, ScriptBuf, TxOut}; - self.tx.output.push(TxOut { - value: Amount::from_sat(value), - script_pubkey: ScriptBuf::from(script.to_vec()), - }); - self.tx.output.len() - 1 + if index > self.tx.output.len() { + return Err(WasmUtxoError::new(&format!( + "Output index {} out of bounds (have {} outputs)", + index, + self.tx.output.len() + ))); + } + self.tx.output.insert( + index, + TxOut { + value: Amount::from_sat(value), + script_pubkey: ScriptBuf::from(script.to_vec()), + }, + ); + Ok(index) + } + + pub fn add_output(&mut self, script: &[u8], value: u64) -> usize { + self.add_output_at_index(self.tx.output.len(), script, value) + .expect("insert at len should never fail") } /// Deserialize a transaction from bytes diff --git a/packages/wasm-utxo/test/bip322/index.ts b/packages/wasm-utxo/test/bip322/index.ts index 9494a6a3..050432d4 100644 --- a/packages/wasm-utxo/test/bip322/index.ts +++ b/packages/wasm-utxo/test/bip322/index.ts @@ -38,8 +38,8 @@ describe("BIP-0322", function () { }); assert.strictEqual(inputIndex, 0, "First input should have index 0"); - assert.strictEqual(psbt.version, 0, "BIP-0322 PSBTs must have version 0"); - assert.strictEqual(psbt.lockTime, 0, "BIP-0322 PSBTs must have lockTime 0"); + assert.strictEqual(psbt.version(), 0, "BIP-0322 PSBTs must have version 0"); + assert.strictEqual(psbt.lockTime(), 0, "BIP-0322 PSBTs must have lockTime 0"); }); it("should add a valid BIP-0322 input for p2wsh", function () { @@ -52,7 +52,7 @@ describe("BIP-0322", function () { }); assert.strictEqual(inputIndex, 0); - assert.strictEqual(psbt.version, 0); + assert.strictEqual(psbt.version(), 0); }); it("should add multiple BIP-0322 inputs", function () { @@ -77,7 +77,7 @@ describe("BIP-0322", function () { assert.strictEqual(idx0, 0); assert.strictEqual(idx1, 1); assert.strictEqual(idx2, 2); - assert.strictEqual(psbt.version, 0); + assert.strictEqual(psbt.version(), 0); }); it("should throw for non-version-0 PSBT", function () { diff --git a/packages/wasm-utxo/test/fixedScript/finalizeExtract.ts b/packages/wasm-utxo/test/fixedScript/finalizeExtract.ts index 23fd9c06..10c532f0 100644 --- a/packages/wasm-utxo/test/fixedScript/finalizeExtract.ts +++ b/packages/wasm-utxo/test/fixedScript/finalizeExtract.ts @@ -35,8 +35,8 @@ describe("finalize and extract transaction", function () { // Verify the deserialized PSBT has the same unsigned txid assert.strictEqual( - deserialized.unsignedTxid(), - fullsignedBitgoPsbt.unsignedTxid(), + deserialized.unsignedTxId(), + fullsignedBitgoPsbt.unsignedTxId(), "Deserialized PSBT should have same unsigned txid after round-trip", ); @@ -46,8 +46,8 @@ describe("finalize and extract transaction", function () { // Verify functional equivalence by deserializing again and checking txid const redeserialized = fixedScriptWallet.BitGoPsbt.fromBytes(reserialized, networkName); assert.strictEqual( - redeserialized.unsignedTxid(), - fullsignedBitgoPsbt.unsignedTxid(), + redeserialized.unsignedTxId(), + fullsignedBitgoPsbt.unsignedTxId(), "PSBT should maintain consistency through multiple serialize/deserialize cycles", ); }); diff --git a/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts b/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts index 306dce73..6e78524d 100644 --- a/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts +++ b/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts @@ -58,12 +58,12 @@ describe("parseTransactionWithWalletKeys", function () { }); it("should have matching unsigned transaction ID", function () { - const unsignedTxid = bitgoPsbt.unsignedTxid(); + const unsignedTxId = bitgoPsbt.unsignedTxId(); const expectedUnsignedTxid = utxolib.bitgo .createPsbtFromBuffer(fullsignedPsbtBytes, network) .getUnsignedTx() .getId(); - assert.strictEqual(unsignedTxid, expectedUnsignedTxid); + assert.strictEqual(unsignedTxId, expectedUnsignedTxid); }); it("should parse transaction and identify internal/external outputs", function () { diff --git a/packages/wasm-utxo/test/fixedScript/psbtReconstruction.ts b/packages/wasm-utxo/test/fixedScript/psbtReconstruction.ts index a5b29f2f..5fc78010 100644 --- a/packages/wasm-utxo/test/fixedScript/psbtReconstruction.ts +++ b/packages/wasm-utxo/test/fixedScript/psbtReconstruction.ts @@ -97,16 +97,16 @@ describe("PSBT reconstruction", function () { rootWalletKeys, { consensusBranchId: ZCASH_SAPLING_BRANCH_ID, - version: zcashPsbt.version, - lockTime: zcashPsbt.lockTime, + version: zcashPsbt.version(), + lockTime: zcashPsbt.lockTime(), versionGroupId: zcashPsbt.versionGroupId, expiryHeight: zcashPsbt.expiryHeight, }, ); } else { reconstructed = BitGoPsbt.createEmpty(networkName, rootWalletKeys, { - version: originalPsbt.version, - lockTime: originalPsbt.lockTime, + version: originalPsbt.version(), + lockTime: originalPsbt.lockTime(), }); } @@ -182,30 +182,30 @@ describe("PSBT reconstruction", function () { // Compare unsigned txids assert.strictEqual( - reconstructed.unsignedTxid(), - originalPsbt.unsignedTxid(), + 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"); + assert.strictEqual(typeof originalPsbt.version(), "number", "version should be a number"); + assert.strictEqual(typeof originalPsbt.lockTime(), "number", "lockTime should be a number"); // Version depends on network: Zcash uses version 4 (Sapling) or 5 (NU5), others use 1 or 2 if (network === utxolib.networks.zcash) { assert.ok( - originalPsbt.version === 4 || originalPsbt.version === 5, - `Zcash version should be 4 or 5, got ${originalPsbt.version}`, + originalPsbt.version() === 4 || originalPsbt.version() === 5, + `Zcash version should be 4 or 5, got ${originalPsbt.version()}`, ); } else { assert.ok( - originalPsbt.version === 1 || originalPsbt.version === 2, - `version should be 1 or 2, got ${originalPsbt.version}`, + 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"); + assert.strictEqual(originalPsbt.lockTime(), 0, "lockTime should be 0 for unsigned fixtures"); }); it("should include sequence in parsed inputs", function () { @@ -253,8 +253,8 @@ describe("PSBT reconstruction", function () { rootWalletKeys, { consensusBranchId: ZCASH_SAPLING_BRANCH_ID, - version: zcashPsbt.version, - lockTime: zcashPsbt.lockTime, + version: zcashPsbt.version(), + lockTime: zcashPsbt.lockTime(), versionGroupId: zcashPsbt.versionGroupId, expiryHeight: zcashPsbt.expiryHeight, }, @@ -263,8 +263,8 @@ describe("PSBT reconstruction", function () { // Create PSBT using block height (preferred approach) const psbtWithHeight = ZcashBitGoPsbt.createEmpty(networkName, rootWalletKeys, { blockHeight: saplingHeight, - version: zcashPsbt.version, - lockTime: zcashPsbt.lockTime, + version: zcashPsbt.version(), + lockTime: zcashPsbt.lockTime(), versionGroupId: zcashPsbt.versionGroupId, expiryHeight: zcashPsbt.expiryHeight, }); @@ -332,8 +332,8 @@ describe("PSBT reconstruction", function () { // Verify both PSBTs produce the same unsigned txid assert.strictEqual( - psbtWithHeight.unsignedTxid(), - psbtWithBranchId.unsignedTxid(), + psbtWithHeight.unsignedTxId(), + psbtWithBranchId.unsignedTxId(), "PSBT created with block height should have same unsigned txid as one created with explicit branch ID", ); @@ -363,10 +363,10 @@ describe("PSBT reconstruction", function () { assert.strictEqual(txid.length, 64, "txid should be 64 characters"); assert.match(txid, /^[0-9a-f]{64}$/, "txid should be lowercase hex"); - // Verify unsignedTxid() also returns valid format - const unsignedTxid = psbt.unsignedTxid(); - assert.strictEqual(unsignedTxid.length, 64, "unsignedTxid should be 64 characters"); - assert.match(unsignedTxid, /^[0-9a-f]{64}$/, "unsignedTxid should be lowercase hex"); + // Verify unsignedTxId() also returns valid format + const unsignedTxId = psbt.unsignedTxId(); + assert.strictEqual(unsignedTxId.length, 64, "unsignedTxId should be 64 characters"); + assert.match(unsignedTxId, /^[0-9a-f]{64}$/, "unsignedTxId should be lowercase hex"); }); }); }); From 19d0182acd4fdfc90b424fea7a7cdcf7ea7ed3be Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 4 Mar 2026 15:12:29 +0100 Subject: [PATCH 2/2] feat(wasm-utxo)!: restructure linting scripts for granular control Split monolithic lint commands into individual tasks for prettier, eslint, rustfmt, and clippy. This enables running specific linters independently and provides better control over the linting workflow. BREAKING CHANGE: `check-fmt` now runs prettier and rustfmt instead of both formatters. Use `lint` for comprehensive checks including clippy. Issue: BTC-XXXX Co-authored-by: llm-git --- packages/wasm-utxo/package.json | 13 ++++++++++--- .../test/fixedScript/psbtReconstruction.ts | 6 +++++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/wasm-utxo/package.json b/packages/wasm-utxo/package.json index 1dc42ff3..9edf7116 100644 --- a/packages/wasm-utxo/package.json +++ b/packages/wasm-utxo/package.json @@ -75,9 +75,16 @@ "build:ts": "npm run build:ts-esm && npm run build:ts-cjs", "build:package-json": "echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json", "build": "npm run build:wasm && npm run build:ts && npm run build:package-json", - "check-fmt": "prettier --check . && cargo fmt -- --check", - "lint": "eslint .", - "lint:fix": "eslint . --fix" + "check-fmt": "npm run lint:prettier && npm run lint:rustfmt", + "lint:prettier": "prettier --check .", + "lint:eslint": "eslint .", + "lint:rustfmt": "cargo fmt -- --check", + "lint:clippy": "cargo clippy --all-targets --all-features -- -D warnings", + "lint": "npm run lint:prettier && npm run lint:eslint && npm run lint:rustfmt && npm run lint:clippy", + "lint:prettier:fix": "prettier --write .", + "lint:eslint:fix": "eslint . --fix", + "lint:rustfmt:fix": "cargo fmt", + "lint:fix": "npm run lint:prettier:fix && npm run lint:eslint:fix && npm run lint:rustfmt:fix" }, "devDependencies": { "@bitgo/unspents": "^0.50.13", diff --git a/packages/wasm-utxo/test/fixedScript/psbtReconstruction.ts b/packages/wasm-utxo/test/fixedScript/psbtReconstruction.ts index 5fc78010..ef3f2f55 100644 --- a/packages/wasm-utxo/test/fixedScript/psbtReconstruction.ts +++ b/packages/wasm-utxo/test/fixedScript/psbtReconstruction.ts @@ -205,7 +205,11 @@ describe("PSBT reconstruction", function () { ); } // LockTime is typically 0 for these fixtures - assert.strictEqual(originalPsbt.lockTime(), 0, "lockTime should be 0 for unsigned fixtures"); + assert.strictEqual( + originalPsbt.lockTime(), + 0, + "lockTime should be 0 for unsigned fixtures", + ); }); it("should include sequence in parsed inputs", function () {