Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/wasm-utxo/js/fixedScriptWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
48 changes: 48 additions & 0 deletions packages/wasm-utxo/src/wasm/fixed_script_wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,4 +265,52 @@ impl BitGoPsbt {
))
})
}

/// Serialize the PSBT to bytes
///
/// # Returns
/// The serialized PSBT as a byte array
pub fn serialize(&self) -> Result<Vec<u8>, 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<u8>)` containing the serialized transaction bytes
/// - `Err(WasmUtxoError)` if the PSBT is not fully finalized or extraction fails
pub fn extract_transaction(&self) -> Result<Vec<u8>, 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))
}
}
112 changes: 112 additions & 0 deletions packages/wasm-utxo/test/fixedScript/finalizeExtract.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
});
});
10 changes: 10 additions & 0 deletions packages/wasm-utxo/test/fixedScript/fixtureUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,13 @@ export function loadWalletKeysFromFixture(network: string): utxolib.bitgo.RootWa

return new utxolib.bitgo.RootWalletKeys(xpubs as Triple<utxolib.BIP32Interface>);
}

/**
* 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;
}