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
12 changes: 12 additions & 0 deletions packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -989,6 +989,18 @@ export class BitGoPsbt extends PsbtBase<WasmBitGoPsbt> 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
*
Expand Down
20 changes: 20 additions & 0 deletions packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> {
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,
Expand Down
7 changes: 7 additions & 0 deletions packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8> {
self.psbt.get_unsigned_tx_bytes()
}
}

impl_wasm_psbt_ops!(BitGoPsbt, psbt);
165 changes: 165 additions & 0 deletions packages/wasm-utxo/test/fixedScript/getUnsignedTransaction.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
Loading