From 70b0954a8e3380aa1f5e8bc0620393766f7d3b72 Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Tue, 26 May 2026 14:48:50 +0200 Subject: [PATCH] feat(wasm-utxo): add BitGoPsbt.getUnsignedTransaction() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds get_unsigned_tx_bytes() to the BitGoPsbt Rust core, a #[wasm_bindgen] get_unsigned_tx() wrapper in the wasm layer, and getUnsignedTransaction() in the TypeScript BitGoPsbt class. Unlike extractTransaction(), the new method does not require finalization or partial signatures — it serializes the raw unsigned transaction embedded in the PSBT global map, equivalent to utxo-lib's UtxoPsbt.getUnsignedTx().toBuffer(). Refs: T1-3435 --- .../js/fixedScriptWallet/BitGoPsbt.ts | 12 ++ .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 20 +++ .../src/wasm/fixed_script_wallet/mod.rs | 7 + .../fixedScript/getUnsignedTransaction.ts | 165 ++++++++++++++++++ 4 files changed, 204 insertions(+) create mode 100644 packages/wasm-utxo/test/fixedScript/getUnsignedTransaction.ts diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index 93a56b6d34f..13debcd2289 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -989,6 +989,18 @@ export class BitGoPsbt extends PsbtBase implements IPsbtWithAddre return this._wasm.extract_half_signed_legacy_tx(); } + /** + * Serialize the unsigned transaction embedded in this PSBT. + * + * Unlike {@link extractTransaction}, this does NOT require finalization or signatures. + * Equivalent to utxo-lib's `UtxoPsbt.getUnsignedTx().toBuffer()`. + * + * @returns The serialized unsigned transaction bytes (network/consensus encoding). + */ + getUnsignedTransaction(): Uint8Array { + return this._wasm.get_unsigned_tx(); + } + /** * Get all PSBT outputs with resolved address strings * 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 9ce56529ed7..69aff518fa1 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 @@ -1426,6 +1426,26 @@ impl BitGoPsbt { } } + /// Serialize the unsigned transaction embedded in this PSBT. + /// + /// Unlike `extract_half_signed_legacy_tx`, this does NOT require signatures or finalization. + pub fn get_unsigned_tx_bytes(&self) -> Vec { + use miniscript::bitcoin::consensus::Encodable; + match self { + BitGoPsbt::BitcoinLike(psbt, _) => { + let mut buf = Vec::new(); + psbt.unsigned_tx + .consensus_encode(&mut buf) + .expect("encoding to vec should not fail"); + buf + } + BitGoPsbt::Dash(dash_psbt, _) => dash_psbt.unsigned_tx_bytes.clone(), + BitGoPsbt::Zcash(zcash_psbt, _) => zcash_psbt + .extract_unsigned_zcash_transaction() + .expect("Zcash unsigned tx encoding should not fail"), + } + } + pub fn into_psbt(self) -> Psbt { match self { BitGoPsbt::BitcoinLike(psbt, _network) => psbt, 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 b29d216e35a..879c339ff13 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -1933,6 +1933,13 @@ impl BitGoPsbt { .extract_half_signed_legacy_tx() .map_err(|e| WasmUtxoError::new(&e)) } + + /// Serialize the unsigned transaction embedded in this PSBT. + /// + /// Unlike `extract_transaction()`, this does NOT require finalization or signatures. + pub fn get_unsigned_tx(&self) -> Vec { + self.psbt.get_unsigned_tx_bytes() + } } impl_wasm_psbt_ops!(BitGoPsbt, psbt); diff --git a/packages/wasm-utxo/test/fixedScript/getUnsignedTransaction.ts b/packages/wasm-utxo/test/fixedScript/getUnsignedTransaction.ts new file mode 100644 index 00000000000..808255ea90f --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/getUnsignedTransaction.ts @@ -0,0 +1,165 @@ +/** + * Tests for getUnsignedTransaction() method against reference utxo-lib implementation + */ +import { describe, it } from "mocha"; +import * as assert from "assert"; +import * as utxolib from "@bitgo/utxo-lib"; +import { BitGoPsbt } from "../../js/fixedScriptWallet/BitGoPsbt.js"; +import { ZcashBitGoPsbt } from "../../js/fixedScriptWallet/ZcashBitGoPsbt.js"; +import { ChainCode } from "../../js/fixedScriptWallet/chains.js"; +import { ECPair } from "../../js/ecpair.js"; +import { getDefaultWalletKeys, getKeyTriple } from "../../js/testutils/keys.js"; +import { getCoinNameForNetwork } from "../networks.js"; + +// Zcash Nu5 activation height (mainnet) +const ZCASH_NU5_HEIGHT = 1687105; + +const p2msNetworks = utxolib + .getNetworkList() + .filter( + (n) => utxolib.isMainnet(n) && n !== utxolib.networks.bitcoinsv && n !== utxolib.networks.ecash, + ); + +/** + * Create an unsigned PSBT with p2sh inputs across all supported p2ms script types. + */ +function createUnsignedP2msPsbt(network: utxolib.Network): BitGoPsbt { + const coinName = getCoinNameForNetwork(network); + const rootWalletKeys = getDefaultWalletKeys(); + + const supportedTypes = (["p2sh", "p2shP2wsh", "p2wsh"] as const).filter((scriptType) => + utxolib.bitgo.outputScripts.isSupportedScriptType(network, scriptType), + ); + + const isZcash = utxolib.getMainnet(network) === utxolib.networks.zcash; + const psbt = isZcash + ? ZcashBitGoPsbt.createEmpty(coinName as "zec" | "tzec", rootWalletKeys, { + version: 4, + lockTime: 0, + blockHeight: ZCASH_NU5_HEIGHT, + }) + : BitGoPsbt.createEmpty(coinName, rootWalletKeys, { version: 2, lockTime: 0 }); + + supportedTypes.forEach((scriptType, index) => { + const scriptId = { chain: ChainCode.value(scriptType, "external"), index }; + psbt.addWalletInput( + { + txid: `${"00".repeat(31)}${index.toString(16).padStart(2, "0")}`, + vout: 0, + value: BigInt(10000 + index * 10000), + sequence: 0xfffffffd, + }, + rootWalletKeys, + { scriptId }, + ); + }); + + psbt.addWalletOutput(rootWalletKeys, { chain: 0, index: 100, value: BigInt(5000) }); + + return psbt; +} + +/** + * Convert wasm-utxo PSBT bytes to a utxo-lib UtxoPsbt for reference comparisons. + */ +function toUtxolibPsbt(wasmPsbt: BitGoPsbt, network: utxolib.Network): utxolib.bitgo.UtxoPsbt { + return utxolib.bitgo.createPsbtFromBuffer(Buffer.from(wasmPsbt.serialize()), network); +} + +describe("getUnsignedTransaction", function () { + describe("Basic functionality", function () { + it("returns non-empty bytes for an unsigned PSBT", function () { + const psbt = createUnsignedP2msPsbt(utxolib.networks.bitcoin); + const txBytes = psbt.getUnsignedTransaction(); + assert.ok(txBytes.length > 0, "Should return non-empty bytes"); + }); + + it("deserializes as a valid transaction with the expected inputs", function () { + const psbt = createUnsignedP2msPsbt(utxolib.networks.bitcoin); + const txBytes = psbt.getUnsignedTransaction(); + + const tx = utxolib.bitgo.createTransactionFromBuffer( + Buffer.from(txBytes), + utxolib.networks.bitcoin, + { amountType: "bigint" }, + ); + assert.ok(tx, "Should deserialize as valid transaction"); + assert.ok(tx.ins.length >= 1, "Should have at least 1 input"); + assert.ok(tx.outs.length >= 1, "Should have at least 1 output"); + }); + + it("returns identical bytes when called on a half-signed PSBT", function () { + const rootWalletKeys = getDefaultWalletKeys(); + const [userXprv] = getKeyTriple("default"); + + const psbt = BitGoPsbt.createEmpty("btc", rootWalletKeys, { version: 2, lockTime: 0 }); + psbt.addWalletInput( + { txid: "00".repeat(32), vout: 0, value: BigInt(10000), sequence: 0xfffffffd }, + rootWalletKeys, + { scriptId: { chain: 0, index: 0 } }, + ); + psbt.addWalletOutput(rootWalletKeys, { chain: 0, index: 100, value: BigInt(5000) }); + + const unsignedBytes = psbt.getUnsignedTransaction(); + + psbt.sign(userXprv); + const halfSignedBytes = psbt.getUnsignedTransaction(); + + // The embedded unsigned_tx in the PSBT global map is not affected by partial sigs + assert.strictEqual( + Buffer.from(unsignedBytes).toString("hex"), + Buffer.from(halfSignedBytes).toString("hex"), + "Unsigned tx bytes should not change after signing", + ); + }); + }); + + describe("Comparison with utxo-lib getUnsignedTx", function () { + for (const network of p2msNetworks) { + const networkName = utxolib.getNetworkName(network); + it(`${networkName}: matches utxo-lib UtxoPsbt.getUnsignedTx().toBuffer()`, function () { + const psbt = createUnsignedP2msPsbt(network); + + const wasmBytes = psbt.getUnsignedTransaction(); + + const utxolibPsbt = toUtxolibPsbt(psbt, network); + const utxolibBytes = utxolibPsbt.getUnsignedTx().toBuffer(); + + assert.strictEqual( + Buffer.from(wasmBytes).toString("hex"), + utxolibBytes.toString("hex"), + `Unsigned tx bytes should match utxo-lib output for ${networkName}`, + ); + }); + } + }); + + describe("Replay protection inputs", function () { + it("includes replay protection input in the unsigned transaction", function () { + const rootWalletKeys = getDefaultWalletKeys(); + const ecpair = ECPair.fromPublicKey(rootWalletKeys.userKey().publicKey); + + const psbt = BitGoPsbt.createEmpty("btc", rootWalletKeys, { version: 2, lockTime: 0 }); + psbt.addWalletInput( + { txid: "00".repeat(32), vout: 0, value: BigInt(10000), sequence: 0xfffffffd }, + rootWalletKeys, + { scriptId: { chain: 0, index: 0 } }, + ); + psbt.addReplayProtectionInput( + { txid: "aa".repeat(32), vout: 0, value: BigInt(1000), sequence: 0xfffffffd }, + ecpair, + ); + psbt.addWalletOutput(rootWalletKeys, { chain: 0, index: 100, value: BigInt(5000) }); + + const txBytes = psbt.getUnsignedTransaction(); + assert.ok(txBytes.length > 0, "Should produce non-empty bytes"); + + const tx = utxolib.bitgo.createTransactionFromBuffer( + Buffer.from(txBytes), + utxolib.networks.bitcoin, + { amountType: "bigint" }, + ); + assert.strictEqual(tx.ins.length, 2, "Both wallet and replay protection inputs included"); + }); + }); +});