diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index a8a6d88..a8cea65 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -766,14 +766,16 @@ export class BitGoPsbt { */ extractTransaction(): ITransaction { const networkType = this._wasm.get_network_type(); - - if (networkType === "dash") { - return DashTransaction.fromWasm(this._wasm.extract_dash_transaction()); - } - if (networkType === "zcash") { - return ZcashTransaction.fromWasm(this._wasm.extract_zcash_transaction()); + const wasm: unknown = this._wasm.extract_transaction(); + + switch (networkType) { + case "dash": + return DashTransaction.fromWasm(wasm as Parameters[0]); + case "zcash": + return ZcashTransaction.fromWasm(wasm as Parameters[0]); + default: + return Transaction.fromWasm(wasm as Parameters[0]); } - return Transaction.fromWasm(this._wasm.extract_bitcoin_transaction()); } /** diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index 4a6dc12..69d3512 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -111,6 +111,9 @@ declare module "./wasm/wasm_utxo.js" { validateSignatureAtInput(inputIndex: number, pubkey: Uint8Array): boolean; verifySignatureWithKey(inputIndex: number, key: WasmBIP32): boolean; + // Extraction methods + extractTransaction(): WasmTransaction; + // Metadata methods unsignedTxId(): string; lockTime(): number; 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 55ceac2..074b11a 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -1490,17 +1490,40 @@ impl BitGoPsbt { /// Extract the final transaction from a finalized PSBT /// /// This method should be called after all inputs have been finalized. - /// It extracts the fully signed transaction with network-appropriate serialization. + /// It extracts the fully signed transaction as a WASM transaction instance + /// appropriate for the network (WasmTransaction, WasmDashTransaction, or WasmZcashTransaction). /// /// # Returns - /// - `Ok(Vec)` containing the serialized transaction bytes + /// - `Ok(JsValue)` containing the WASM transaction instance /// - `Err(WasmUtxoError)` if the PSBT is not fully finalized or extraction fails - pub fn extract_transaction(&self) -> Result, WasmUtxoError> { - // Clone and use extract_tx() which handles all network-specific serialization - self.psbt - .clone() - .extract_tx() - .map_err(|e| WasmUtxoError::new(&e)) + pub fn extract_transaction(&self) -> Result { + use crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt as InnerBitGoPsbt; + match &self.psbt { + InnerBitGoPsbt::BitcoinLike(..) => { + let tx = self + .psbt + .clone() + .extract_bitcoin_tx() + .map_err(|e| WasmUtxoError::new(&e))?; + Ok(crate::wasm::transaction::WasmTransaction::from_tx(tx).into()) + } + InnerBitGoPsbt::Dash(..) => { + let parts = self + .psbt + .clone() + .extract_dash_tx() + .map_err(|e| WasmUtxoError::new(&e))?; + Ok(crate::wasm::dash_transaction::WasmDashTransaction::from_parts(parts).into()) + } + InnerBitGoPsbt::Zcash(..) => { + let parts = self + .psbt + .clone() + .extract_zcash_tx() + .map_err(|e| WasmUtxoError::new(&e))?; + Ok(crate::wasm::transaction::WasmZcashTransaction::from_parts(parts).into()) + } + } } /// Extract the final transaction as a WasmTransaction (for BitcoinLike networks) diff --git a/packages/wasm-utxo/src/wasm/psbt.rs b/packages/wasm-utxo/src/wasm/psbt.rs index 9903f0e..56ac8e2 100644 --- a/packages/wasm-utxo/src/wasm/psbt.rs +++ b/packages/wasm-utxo/src/wasm/psbt.rs @@ -521,6 +521,25 @@ impl WrapPsbt { }) } + /// Extract the final transaction from a finalized PSBT + /// + /// This method should be called after all inputs have been finalized. + /// It extracts the fully signed transaction as a WasmTransaction instance. + /// + /// # Returns + /// - `Ok(WasmTransaction)` containing the extracted transaction + /// - `Err(WasmUtxoError)` if the PSBT is not fully finalized or extraction fails + #[wasm_bindgen(js_name = extractTransaction)] + pub fn extract_transaction( + &self, + ) -> Result { + let tx = + self.0.clone().extract_tx().map_err(|e| { + WasmUtxoError::new(&format!("Failed to extract transaction: {}", e)) + })?; + Ok(crate::wasm::transaction::WasmTransaction::from_tx(tx)) + } + /// Get the number of inputs in the PSBT #[wasm_bindgen(js_name = inputCount)] pub fn input_count(&self) -> usize { diff --git a/packages/wasm-utxo/test/psbtFromDescriptor.ts b/packages/wasm-utxo/test/psbtFromDescriptor.ts index 986f46b..ddc7a8d 100644 --- a/packages/wasm-utxo/test/psbtFromDescriptor.ts +++ b/packages/wasm-utxo/test/psbtFromDescriptor.ts @@ -5,6 +5,7 @@ import { getKey } from "@bitgo/utxo-lib/dist/src/testutil"; import { DescriptorNode, formatNode } from "../js/ast/index.js"; import { mockPsbtDefault } from "./psbtFromDescriptor.util.js"; import { Descriptor } from "../js/index.js"; +import { WasmTransaction } from "../js/wasm/wasm_utxo.js"; import { toWrappedPsbt } from "./psbt.util.js"; function toKeyWithPath(k: BIP32Interface, path = "*"): string { @@ -154,3 +155,77 @@ describeSignDescriptor( ), { signECPair: [[toECPair(a)]] }, ); + +describe("WrapPsbt extractTransaction", function () { + function signFinalizeExtract(descriptor: Descriptor, signKeys: BIP32Interface[]) { + const psbt = mockPsbtDefault({ + descriptorSelf: descriptor, + descriptorOther: Descriptor.fromString( + formatNode({ wpkh: toKeyWithPath(external) }), + "derivable", + ), + }); + const wrappedPsbt = toWrappedPsbt(psbt); + for (const key of signKeys) { + wrappedPsbt.signWithXprv(key.toBase58()); + } + wrappedPsbt.finalize(); + return wrappedPsbt.extractTransaction(); + } + + it("should extract transaction from finalized Wsh2Of3 PSBT", function () { + const tx = signFinalizeExtract( + fromNodes( + { wsh: { multi: [2, toKeyWithPath(a), toKeyWithPath(b), toKeyWithPath(c)] } }, + "derivable", + ), + [a, b], + ); + + assert.strictEqual(typeof tx.get_txid(), "string"); + assert.strictEqual(tx.get_txid().length, 64); + assert.ok(tx.get_vsize() > 0); + assert.ok(tx.to_bytes().length > 0); + }); + + it("should extract transaction from finalized Tr PSBT", function () { + const tx = signFinalizeExtract( + fromNodes( + { tr: [toKeyWithPath(a), [{ pk: toKeyWithPath(b) }, { pk: toKeyWithPath(c) }]] }, + "derivable", + ), + [a], + ); + + assert.strictEqual(typeof tx.get_txid(), "string"); + assert.strictEqual(tx.get_txid().length, 64); + assert.ok(tx.get_vsize() > 0); + assert.ok(tx.to_bytes().length > 0); + }); + + it("should produce consistent txid across repeated calls", function () { + const tx = signFinalizeExtract( + fromNodes( + { wsh: { multi: [2, toKeyWithPath(a), toKeyWithPath(b), toKeyWithPath(c)] } }, + "derivable", + ), + [a, b], + ); + + assert.strictEqual(tx.get_txid(), tx.get_txid()); + }); + + it("should produce a transaction whose bytes round-trip to the same txid", function () { + const tx = signFinalizeExtract( + fromNodes( + { wsh: { multi: [2, toKeyWithPath(a), toKeyWithPath(b), toKeyWithPath(c)] } }, + "derivable", + ), + [a, b], + ); + + const txBytes = tx.to_bytes(); + const tx2 = WasmTransaction.from_bytes(txBytes); + assert.strictEqual(tx2.get_txid(), tx.get_txid()); + }); +});