diff --git a/packages/wasm-utxo/js/fixedScriptWallet.ts b/packages/wasm-utxo/js/fixedScriptWallet.ts index 95665475..56c1dd70 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet.ts @@ -169,4 +169,32 @@ export class BitGoPsbt { verifyReplayProtectionSignature(inputIndex: number, replayProtection: ReplayProtection): boolean { return this.wasm.verify_replay_protection_signature(inputIndex, replayProtection); } + + /** + * Serialize the PSBT to bytes + * + * @returns The serialized PSBT as a byte array + */ + serialize(): Uint8Array { + return this.wasm.serialize(); + } + + /** + * Finalize all inputs in the PSBT + * + * @throws Error if any input failed to finalize + */ + finalizeAllInputs(): void { + this.wasm.finalize_all_inputs(); + } + + /** + * Extract the final transaction from a finalized PSBT + * + * @returns The serialized transaction bytes + * @throws Error if the PSBT is not fully finalized or extraction fails + */ + extractTransaction(): Uint8Array { + return this.wasm.extract_transaction(); + } } diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs index 5fe10b42..090385ca 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet.rs @@ -265,4 +265,52 @@ impl BitGoPsbt { )) }) } + + /// Serialize the PSBT to bytes + /// + /// # Returns + /// The serialized PSBT as a byte array + pub fn serialize(&self) -> Result, WasmUtxoError> { + self.psbt + .serialize() + .map_err(|e| WasmUtxoError::new(&format!("Failed to serialize PSBT: {}", e))) + } + + /// Finalize all inputs in the PSBT + /// + /// This method attempts to finalize all inputs in the PSBT, computing the final + /// scriptSig and witness data for each input. + /// + /// # Returns + /// - `Ok(())` if all inputs were successfully finalized + /// - `Err(WasmUtxoError)` if any input failed to finalize + pub fn finalize_all_inputs(&mut self) -> Result<(), WasmUtxoError> { + let secp = miniscript::bitcoin::secp256k1::Secp256k1::verification_only(); + self.psbt.finalize_mut(&secp).map_err(|errors| { + WasmUtxoError::new(&format!( + "Failed to finalize {} input(s): {}", + errors.len(), + errors.join("; ") + )) + }) + } + + /// 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. + /// + /// # Returns + /// - `Ok(Vec)` containing the serialized transaction bytes + /// - `Err(WasmUtxoError)` if the PSBT is not fully finalized or extraction fails + pub fn extract_transaction(&self) -> Result, WasmUtxoError> { + let psbt = self.psbt.psbt().clone(); + let tx = psbt + .extract_tx() + .map_err(|e| WasmUtxoError::new(&format!("Failed to extract transaction: {}", e)))?; + + // Serialize the transaction + use miniscript::bitcoin::consensus::encode::serialize; + Ok(serialize(&tx)) + } } diff --git a/packages/wasm-utxo/test/fixedScript/finalizeExtract.ts b/packages/wasm-utxo/test/fixedScript/finalizeExtract.ts new file mode 100644 index 00000000..bb704032 --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/finalizeExtract.ts @@ -0,0 +1,112 @@ +import assert from "node:assert"; +import * as utxolib from "@bitgo/utxo-lib"; +import { fixedScriptWallet } from "../../js/index.js"; +import { + loadPsbtFixture, + getPsbtBuffer, + getExtractedTransactionHex, + type Fixture, +} from "./fixtureUtil.js"; + +describe("finalize and extract transaction", function () { + const supportedNetworks = utxolib.getNetworkList().filter((network) => { + return ( + utxolib.isMainnet(network) && + network !== utxolib.networks.bitcoincash && + network !== utxolib.networks.bitcoingold && + network !== utxolib.networks.bitcoinsv && + network !== utxolib.networks.ecash && + network !== utxolib.networks.zcash + ); + }); + + supportedNetworks.forEach((network) => { + const networkName = utxolib.getNetworkName(network); + + describe(`network: ${networkName}`, function () { + let fullsignedFixture: Fixture; + let fullsignedPsbtBuffer: Buffer; + let fullsignedBitgoPsbt: fixedScriptWallet.BitGoPsbt; + + before(function () { + fullsignedFixture = loadPsbtFixture(networkName, "fullsigned"); + fullsignedPsbtBuffer = getPsbtBuffer(fullsignedFixture); + fullsignedBitgoPsbt = fixedScriptWallet.BitGoPsbt.fromBytes( + fullsignedPsbtBuffer, + networkName, + ); + }); + + it("should serialize and deserialize PSBT (round-trip)", function () { + const serialized = fullsignedBitgoPsbt.serialize(); + + // Verify we can deserialize what we serialized (functional round-trip) + const deserialized = fixedScriptWallet.BitGoPsbt.fromBytes(serialized, networkName); + + // Verify the deserialized PSBT has the same unsigned txid + assert.strictEqual( + deserialized.unsignedTxid(), + fullsignedBitgoPsbt.unsignedTxid(), + "Deserialized PSBT should have same unsigned txid after round-trip", + ); + + // Verify the re-deserialized PSBT can be serialized back to bytes + const reserialized = deserialized.serialize(); + + // Verify functional equivalence by deserializing again and checking txid + const redeserialized = fixedScriptWallet.BitGoPsbt.fromBytes(reserialized, networkName); + assert.strictEqual( + redeserialized.unsignedTxid(), + fullsignedBitgoPsbt.unsignedTxid(), + "PSBT should maintain consistency through multiple serialize/deserialize cycles", + ); + }); + + it("should finalize all inputs and be extractable", function () { + // Create a fresh instance for finalization + const psbt = fixedScriptWallet.BitGoPsbt.fromBytes(fullsignedPsbtBuffer, networkName); + + // Finalize all inputs + psbt.finalizeAllInputs(); + + // Serialize the finalized PSBT + const serialized = psbt.serialize(); + + // Verify we can deserialize the finalized PSBT + const deserialized = fixedScriptWallet.BitGoPsbt.fromBytes(serialized, networkName); + + // Verify it can be extracted (which confirms finalization worked) + const extractedTx = deserialized.extractTransaction(); + const extractedTxHex = Buffer.from(extractedTx).toString("hex"); + const expectedTxHex = getExtractedTransactionHex(fullsignedFixture); + + assert.strictEqual( + extractedTxHex, + expectedTxHex, + "Extracted transaction from finalized PSBT should match expected transaction", + ); + }); + + it("should extract transaction from finalized PSBT", function () { + // Create a fresh instance for extraction + const psbt = fixedScriptWallet.BitGoPsbt.fromBytes(fullsignedPsbtBuffer, networkName); + + // Finalize all inputs + psbt.finalizeAllInputs(); + + // Extract transaction + const extractedTx = psbt.extractTransaction(); + const extractedTxHex = Buffer.from(extractedTx).toString("hex"); + + // Get expected transaction hex from fixture + const expectedTxHex = getExtractedTransactionHex(fullsignedFixture); + + assert.strictEqual( + extractedTxHex, + expectedTxHex, + "Extracted transaction should match expected transaction", + ); + }); + }); + }); +}); diff --git a/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts b/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts index 28c9bd66..868ee53c 100644 --- a/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts +++ b/packages/wasm-utxo/test/fixedScript/fixtureUtil.ts @@ -136,3 +136,13 @@ export function loadWalletKeysFromFixture(network: string): utxolib.bitgo.RootWa return new utxolib.bitgo.RootWalletKeys(xpubs as Triple); } + +/** + * Get extracted transaction hex from fixture + */ +export function getExtractedTransactionHex(fixture: Fixture): string { + if (fixture.extractedTransaction === null) { + throw new Error("Fixture does not have an extracted transaction"); + } + return fixture.extractedTransaction; +}