diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index d86c6698..cf221ee4 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -761,4 +761,33 @@ export class BitGoPsbt { extractTransaction(): Uint8Array { return this._wasm.extract_transaction(); } + + /** + * Extract a half-signed transaction in legacy format for p2ms-based script types. + * + * This method extracts a transaction where each input has exactly one signature, + * formatted in the legacy style used by utxo-lib and bitcoinjs-lib. The legacy + * format places signatures in the correct position (0, 1, or 2) based on which + * key signed, with empty placeholders for unsigned positions. + * + * Requirements: + * - All inputs must be p2ms-based (p2sh, p2shP2wsh, or p2wsh) + * - Each input must have exactly 1 partial signature + * + * @returns The serialized half-signed transaction bytes + * @throws Error if any input is not a p2ms type (Taproot, replay protection, etc.) + * @throws Error if any input has 0 or more than 1 partial signature + * + * @example + * ```typescript + * // Sign with user key only + * psbt.sign(userXpriv); + * + * // Extract half-signed transaction in legacy format + * const halfSignedTx = psbt.getHalfSignedLegacyFormat(); + * ``` + */ + getHalfSignedLegacyFormat(): Uint8Array { + return this._wasm.extract_half_signed_legacy_tx(); + } } diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/legacy_txformat.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/legacy_txformat.rs new file mode 100644 index 00000000..ac1372e6 --- /dev/null +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/legacy_txformat.rs @@ -0,0 +1,149 @@ +//! Legacy transaction format extraction for half-signed transactions. +//! +//! This module provides functionality to extract half-signed transactions in the +//! legacy format used by utxo-lib and bitcoinjs-lib, where signatures are placed +//! in scriptSig/witness with OP_0 placeholders for missing signatures. + +use crate::fixed_script_wallet::wallet_scripts::parse_multisig_script_2_of_3; +use miniscript::bitcoin::blockdata::opcodes::all::OP_PUSHBYTES_0; +use miniscript::bitcoin::blockdata::script::Builder; +use miniscript::bitcoin::psbt::Psbt; +use miniscript::bitcoin::script::PushBytesBuf; +use miniscript::bitcoin::{Transaction, Witness}; + +/// Build a half-signed transaction in legacy format from a PSBT. +/// +/// Returns the Transaction with signatures placed in scriptSig/witness. +/// Use `extract_half_signed_legacy_tx` for serialized bytes. +pub fn build_half_signed_legacy_tx(psbt: &Psbt) -> Result { + // Validate we have inputs and outputs + if psbt.inputs.is_empty() || psbt.unsigned_tx.output.is_empty() { + return Err("empty inputs or outputs".to_string()); + } + + // Clone the unsigned transaction - we'll set scriptSig/witness on this + let mut tx = psbt.unsigned_tx.clone(); + + for (input_index, psbt_input) in psbt.inputs.iter().enumerate() { + // Determine script type and get the multisig script + let (is_p2sh, is_p2wsh, multisig_script) = + if let Some(ref witness_script) = psbt_input.witness_script { + // p2wsh or p2shP2wsh - witness_script contains the multisig script + let is_p2sh = psbt_input.redeem_script.is_some(); + (is_p2sh, true, witness_script.clone()) + } else if let Some(ref redeem_script) = psbt_input.redeem_script { + // p2sh only - redeem_script contains the multisig script + (true, false, redeem_script.clone()) + } else { + return Err(format!( + "Input {}: unsupported script type (no witness_script or redeem_script found). \ + Only p2ms-based types (p2sh, p2shP2wsh, p2wsh) are supported.", + input_index + )); + }; + + // Check for taproot inputs (not supported) + if !psbt_input.tap_script_sigs.is_empty() || !psbt_input.tap_key_origins.is_empty() { + return Err(format!( + "Input {}: Taproot inputs are not supported in legacy half-signed format", + input_index + )); + } + + // Validate exactly 1 partial signature + let sig_count = psbt_input.partial_sigs.len(); + if sig_count != 1 { + return Err(format!( + "Input {}: expected exactly 1 partial signature, got {}", + input_index, sig_count + )); + } + + // Get the single partial signature + let (sig_pubkey, ecdsa_sig) = psbt_input.partial_sigs.iter().next().unwrap(); + + // Parse the multisig script to get the 3 public keys + let pubkeys = parse_multisig_script_2_of_3(&multisig_script).map_err(|e| { + format!( + "Input {}: failed to parse multisig script: {}", + input_index, e + ) + })?; + + // Find which key index (0, 1, 2) matches the signature's pubkey + let sig_key_index = pubkeys + .iter() + .position(|pk| pk.to_bytes() == sig_pubkey.to_bytes()[..]) + .ok_or_else(|| { + format!( + "Input {}: signature pubkey not found in multisig script", + input_index + ) + })?; + + // Serialize the signature + let sig_bytes = ecdsa_sig.to_vec(); + + // Build the signatures array with the signature in the correct position + // Format: [OP_0, sig_or_empty, sig_or_empty, sig_or_empty] + let mut sig_stack: Vec> = vec![vec![]]; // Start with OP_0 (empty) + for i in 0..3 { + if i == sig_key_index { + sig_stack.push(sig_bytes.clone()); + } else { + sig_stack.push(vec![]); // OP_0 placeholder + } + } + + // Build scriptSig and/or witness based on script type + if is_p2wsh { + // p2wsh or p2shP2wsh: witness = [empty, sigs..., witnessScript] + let mut witness_items = sig_stack; + witness_items.push(multisig_script.to_bytes()); + tx.input[input_index].witness = Witness::from_slice(&witness_items); + + if is_p2sh { + // p2shP2wsh: also need scriptSig = [redeemScript] + // The redeemScript is the p2wsh script (hash of witness script) + let redeem_script = psbt_input.redeem_script.as_ref().unwrap(); + let redeem_script_bytes = PushBytesBuf::try_from(redeem_script.to_bytes()) + .map_err(|e| { + format!( + "Input {}: failed to convert redeem script to push bytes: {}", + input_index, e + ) + })?; + let script_sig = Builder::new().push_slice(redeem_script_bytes).into_script(); + tx.input[input_index].script_sig = script_sig; + } + } else { + // p2sh only: scriptSig = [OP_0, sigs..., redeemScript] + let mut builder = Builder::new().push_opcode(OP_PUSHBYTES_0); + for i in 0..3 { + if i == sig_key_index { + let sig_push_bytes = + PushBytesBuf::try_from(sig_bytes.clone()).map_err(|e| { + format!( + "Input {}: failed to convert signature to push bytes: {}", + input_index, e + ) + })?; + builder = builder.push_slice(sig_push_bytes); + } else { + builder = builder.push_opcode(OP_PUSHBYTES_0); + } + } + let multisig_push_bytes = + PushBytesBuf::try_from(multisig_script.to_bytes()).map_err(|e| { + format!( + "Input {}: failed to convert multisig script to push bytes: {}", + input_index, e + ) + })?; + builder = builder.push_slice(multisig_push_bytes); + tx.input[input_index].script_sig = builder.into_script(); + } + } + + Ok(tx) +} 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 086e7dc3..2194441e 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 @@ -4,6 +4,7 @@ //! bitcoin-like networks, including those with non-standard transaction formats. pub mod dash_psbt; +mod legacy_txformat; pub mod p2tr_musig2_input; #[cfg(test)] mod p2tr_musig2_input_utxolib; @@ -1146,6 +1147,52 @@ impl BitGoPsbt { } } + /// Extract a half-signed transaction in legacy format for p2ms-based script types. + /// + /// This method extracts a transaction where each input has exactly one signature, + /// formatted in the legacy style by utxo-lib and bitcoinjs-lib. The legacy + /// format places signatures in the correct position (0, 1, or 2) based on which + /// key signed, with empty placeholders for unsigned positions. + /// + /// # Requirements + /// - All inputs must be p2ms-based (p2sh, p2shP2wsh, or p2wsh) + /// - Each input must have exactly 1 partial signature + /// + /// # Returns + /// * `Ok(Vec)` - The serialized half-signed transaction bytes (network-native format) + /// * `Err(String)` - If validation fails or extraction fails + /// + /// # Errors + /// - Returns error if any input is not a p2ms type (Taproot, replay protection, etc.) + /// - Returns error if any input has 0 or more than 1 partial signature + pub fn extract_half_signed_legacy_tx(&self) -> Result, String> { + use miniscript::bitcoin::consensus::serialize; + + match self { + BitGoPsbt::BitcoinLike(_, _) | BitGoPsbt::Dash(_, _) => { + let tx = legacy_txformat::build_half_signed_legacy_tx(self.psbt())?; + Ok(serialize(&tx)) + } + BitGoPsbt::Zcash(zcash_psbt, _) => { + let tx = legacy_txformat::build_half_signed_legacy_tx(&zcash_psbt.psbt)?; + + // Serialize with Zcash-specific fields + let parts = crate::zcash::transaction::ZcashTransactionParts { + transaction: tx, + is_overwintered: true, + version_group_id: Some( + zcash_psbt + .version_group_id + .unwrap_or(zcash_psbt::ZCASH_SAPLING_VERSION_GROUP_ID), + ), + expiry_height: Some(zcash_psbt.expiry_height.unwrap_or(0)), + sapling_fields: zcash_psbt.sapling_fields.clone(), + }; + crate::zcash::transaction::encode_zcash_transaction_parts(&parts) + } + } + } + pub fn into_psbt(self) -> Psbt { match self { BitGoPsbt::BitcoinLike(psbt, _network) => psbt, @@ -3609,6 +3656,188 @@ mod tests { ); }); + /// Test extract_half_signed_legacy_tx for p2ms-based script types + fn test_extract_half_signed_legacy_tx_for_script_type( + network: Network, + format: fixtures::TxFormat, + script_type: fixtures::ScriptType, + ) -> Result<(), String> { + use miniscript::bitcoin::consensus::deserialize; + use miniscript::bitcoin::Transaction; + + // Skip non-p2ms script types (they're expected to fail) + let is_p2ms = matches!( + script_type, + fixtures::ScriptType::P2sh + | fixtures::ScriptType::P2shP2wsh + | fixtures::ScriptType::P2wsh + ); + if !is_p2ms { + return Ok(()); + } + + // Check if the script type is supported by the network + let output_script_support = network.output_script_support(); + if !script_type.is_supported_by(&output_script_support) { + return Ok(()); + } + + // Load halfsigned fixture + let fixture = fixtures::load_psbt_fixture_with_format( + network.to_utxolib_name(), + fixtures::SignatureState::Halfsigned, + format, + ) + .map_err(|e| format!("Failed to load halfsigned fixture: {}", e))?; + + let bitgo_psbt = fixture + .to_bitgo_psbt(network) + .map_err(|e| format!("Failed to convert to BitGo PSBT: {}", e))?; + + // Find inputs with the specified script type + let psbt = bitgo_psbt.psbt(); + let has_matching_input = psbt.inputs.iter().any(|input| { + // Check if this input matches the script type we're testing + if let Some(ref witness_script) = input.witness_script { + // p2wsh or p2shP2wsh + if input.redeem_script.is_some() { + matches!(script_type, fixtures::ScriptType::P2shP2wsh) + } else { + matches!(script_type, fixtures::ScriptType::P2wsh) + } + } else if input.redeem_script.is_some() { + // p2sh only + matches!(script_type, fixtures::ScriptType::P2sh) + } else { + false + } + }); + + if !has_matching_input { + // No inputs of this script type in the fixture + return Ok(()); + } + + // Check if all inputs are p2ms types (the method only works with p2ms inputs) + // Also verify the script is actually a 2-of-3 multisig (not P2PK or other) + let all_p2ms = psbt.inputs.iter().all(|input| { + use crate::fixed_script_wallet::wallet_scripts::parse_multisig_script_2_of_3; + + let multisig_script = if let Some(ref ws) = input.witness_script { + ws.clone() + } else if let Some(ref rs) = input.redeem_script { + rs.clone() + } else { + return false; + }; + + // Check it's actually a 2-of-3 multisig script (not P2PK or other) + parse_multisig_script_2_of_3(&multisig_script).is_ok() + }); + + if !all_p2ms { + // Fixture has non-p2ms inputs (e.g., P2SH-P2PK replay protection), skip this test + return Ok(()); + } + + // Check all inputs have exactly 1 signature + let all_have_one_sig = psbt + .inputs + .iter() + .all(|input| input.partial_sigs.len() == 1); + + if !all_have_one_sig { + // Not all inputs are properly half-signed + return Ok(()); + } + + // Try to extract half-signed legacy tx + let result = bitgo_psbt.extract_half_signed_legacy_tx(); + + match result { + Ok(tx_bytes) => { + // Verify the transaction can be deserialized + let tx: Transaction = deserialize(&tx_bytes) + .map_err(|e| format!("Failed to deserialize extracted tx: {}", e))?; + + // Verify each input has appropriate script data + for (i, input) in tx.input.iter().enumerate() { + let psbt_input = &psbt.inputs[i]; + + if psbt_input.witness_script.is_some() { + // p2wsh or p2shP2wsh: should have witness data + assert!( + !input.witness.is_empty(), + "Input {} should have witness data", + i + ); + } else { + // p2sh: should have non-empty script_sig + assert!( + !input.script_sig.is_empty(), + "Input {} should have script_sig", + i + ); + } + } + + Ok(()) + } + Err(e) => { + // Expected error for certain cases (mixed inputs, etc.) + Err(format!("extract_half_signed_legacy_tx failed: {}", e)) + } + } + } + + crate::test_psbt_fixtures!( + test_extract_half_signed_legacy_tx_p2sh, + network, + format, + { + test_extract_half_signed_legacy_tx_for_script_type( + network, + format, + fixtures::ScriptType::P2sh, + ) + .unwrap(); + }, + // Skip Zcash as it may have different half-signed fixture format + ignore: [Zcash] + ); + + crate::test_psbt_fixtures!( + test_extract_half_signed_legacy_tx_p2shp2wsh, + network, + format, + { + test_extract_half_signed_legacy_tx_for_script_type( + network, + format, + fixtures::ScriptType::P2shP2wsh, + ) + .unwrap(); + }, + // Skip networks without segwit + ignore: [BitcoinCash, Ecash, BitcoinGold, Dogecoin, Zcash] + ); + + crate::test_psbt_fixtures!( + test_extract_half_signed_legacy_tx_p2wsh, + network, + format, + { + test_extract_half_signed_legacy_tx_for_script_type( + network, + format, + fixtures::ScriptType::P2wsh, + ) + .unwrap(); + }, + // Skip networks without segwit + ignore: [BitcoinCash, Ecash, BitcoinGold, Dogecoin, Zcash] + ); + #[test] fn test_add_paygo_attestation() { use crate::test_utils::fixtures; 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 38531ad1..f9bd3a03 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -1489,4 +1489,28 @@ impl BitGoPsbt { .extract_tx() .map_err(|e| WasmUtxoError::new(&e)) } + + /// Extract a half-signed transaction in legacy format for p2ms-based script types. + /// + /// This method extracts a transaction where each input has exactly one signature, + /// formatted in the legacy style used by utxo-lib and bitcoinjs-lib. The legacy + /// format places signatures in the correct position (0, 1, or 2) based on which + /// key signed, with empty placeholders for unsigned positions. + /// + /// # Requirements + /// - All inputs must be p2ms-based (p2sh, p2shP2wsh, or p2wsh) + /// - Each input must have exactly 1 partial signature + /// + /// # Returns + /// - `Ok(Vec)` containing the serialized half-signed transaction bytes + /// - `Err(WasmUtxoError)` if validation fails or extraction fails + /// + /// # Errors + /// - Returns error if any input is not a p2ms type (Taproot, replay protection, etc.) + /// - Returns error if any input has 0 or more than 1 partial signature + pub fn extract_half_signed_legacy_tx(&self) -> Result, WasmUtxoError> { + self.psbt + .extract_half_signed_legacy_tx() + .map_err(|e| WasmUtxoError::new(&e)) + } } diff --git a/packages/wasm-utxo/test/address/utxolibCompat.ts b/packages/wasm-utxo/test/address/utxolibCompat.ts index 7aedd309..d2b98d18 100644 --- a/packages/wasm-utxo/test/address/utxolibCompat.ts +++ b/packages/wasm-utxo/test/address/utxolibCompat.ts @@ -5,67 +5,14 @@ import { dirname } from "node:path"; import * as utxolib from "@bitgo/utxo-lib"; import assert from "node:assert"; -import { - utxolibCompat, - address as addressNs, - type CoinName, - AddressFormat, -} from "../../js/index.js"; +import { utxolibCompat, address as addressNs, AddressFormat } from "../../js/index.js"; +import { getCoinNameForNetwork } from "../networks.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); type Fixture = [type: string, script: string, address: string]; -function getCoinNameForNetwork(name: string): CoinName { - switch (name) { - case "bitcoin": - return "btc"; - case "testnet": - return "tbtc"; - case "bitcoinTestnet4": - return "tbtc4"; - case "bitcoinPublicSignet": - return "tbtcsig"; - case "bitcoinBitGoSignet": - return "tbtcbgsig"; - case "bitcoincash": - return "bch"; - case "bitcoincashTestnet": - return "tbch"; - case "ecash": - return "bcha"; - case "ecashTest": - return "tbcha"; - case "bitcoingold": - return "btg"; - case "bitcoingoldTestnet": - return "tbtg"; - case "bitcoinsv": - return "bsv"; - case "bitcoinsvTestnet": - return "tbsv"; - case "dash": - return "dash"; - case "dashTest": - return "tdash"; - case "dogecoin": - return "doge"; - case "dogecoinTest": - return "tdoge"; - case "litecoin": - return "ltc"; - case "litecoinTest": - return "tltc"; - case "zcash": - return "zec"; - case "zcashTest": - return "tzec"; - default: - throw new Error(`Unknown network: ${name}`); - } -} - async function getFixtures(name: string, addressFormat?: AddressFormat): Promise { if (name === "bitcoinBitGoSignet") { name = "bitcoinPublicSignet"; @@ -97,7 +44,7 @@ function runTest(network: utxolib.Network, addressFormat?: AddressFormat) { }); it("should convert using coin name", function () { - const coinName = getCoinNameForNetwork(name); + const coinName = getCoinNameForNetwork(network); for (const fixture of fixtures) { const [, script, addressRef] = fixture; diff --git a/packages/wasm-utxo/test/fixedScript/halfSignedLegacyFormat.ts b/packages/wasm-utxo/test/fixedScript/halfSignedLegacyFormat.ts new file mode 100644 index 00000000..0e6d6cf8 --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/halfSignedLegacyFormat.ts @@ -0,0 +1,251 @@ +/** + * Tests for getHalfSignedLegacyFormat() 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 { getDefaultWalletKeys, getKeyTriple } from "../../js/testutils/keys.js"; +import { getCoinNameForNetwork } from "../networks.js"; + +// Zcash Nu5 activation height (mainnet) - use a height after Nu5 activation +const ZCASH_NU5_HEIGHT = 1687105; + +// P2ms script types that are supported by extractP2msOnlyHalfSignedTx +const p2msScriptTypes = ["p2sh", "p2shP2wsh", "p2wsh"] as const; + +// Networks that support p2ms script types (mainnet only, excluding bsv and ecash) +const p2msNetworks = utxolib + .getNetworkList() + .filter( + (n) => utxolib.isMainnet(n) && n !== utxolib.networks.bitcoinsv && n !== utxolib.networks.ecash, + ); + +/** + * Create a PSBT with only p2ms inputs (p2sh, p2shP2wsh, p2wsh) and sign it with user key + */ +function createHalfSignedP2msPsbt(network: utxolib.Network): BitGoPsbt { + const coinName = getCoinNameForNetwork(network); + const rootWalletKeys = getDefaultWalletKeys(); + const xprvTriple = getKeyTriple("default"); + + // Determine which p2ms types are supported by this network + const supportedTypes = p2msScriptTypes.filter((scriptType) => + utxolib.bitgo.outputScripts.isSupportedScriptType(network, scriptType), + ); + + // Create unsigned PSBT - Zcash requires special handling with blockHeight + const isZcash = utxolib.getMainnet(network) === utxolib.networks.zcash; + const psbt = isZcash + ? ZcashBitGoPsbt.createEmpty(coinName as "zec" | "tzec", rootWalletKeys, { + version: 4, // Zcash uses version 4 + lockTime: 0, + blockHeight: ZCASH_NU5_HEIGHT, + }) + : BitGoPsbt.createEmpty(coinName, rootWalletKeys, { + version: 2, + lockTime: 0, + }); + + // Add inputs for each supported p2ms type + 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 }, + ); + }); + + // Add a p2sh output + psbt.addWalletOutput(rootWalletKeys, { chain: 0, index: 100, value: BigInt(5000) }); + + // Sign with user key only (halfsigned) + psbt.sign(xprvTriple[0]); + + return psbt; +} + +/** + * Convert wasm-utxo PSBT to utxo-lib PSBT for comparison + */ +function toUtxolibPsbt(wasmPsbt: BitGoPsbt, network: utxolib.Network): utxolib.bitgo.UtxoPsbt { + const bytes = wasmPsbt.serialize(); + return utxolib.bitgo.createPsbtFromBuffer(Buffer.from(bytes), network); +} + +describe("getHalfSignedLegacyFormat", function () { + describe("Basic functionality", function () { + it("should extract half-signed transaction for p2ms inputs", function () { + const psbt = createHalfSignedP2msPsbt(utxolib.networks.bitcoin); + assert.ok(psbt, "Should create PSBT"); + + const halfSignedTx = psbt.getHalfSignedLegacyFormat(); + assert.ok(halfSignedTx, "Should extract half-signed transaction"); + assert.ok(halfSignedTx.length > 0, "Transaction should have data"); + + // Verify it's a valid transaction by deserializing + const tx = utxolib.bitgo.createTransactionFromBuffer( + Buffer.from(halfSignedTx), + utxolib.networks.bitcoin, + { amountType: "bigint" }, + ); + assert.ok(tx, "Should deserialize as valid transaction"); + // Should have at least 1 input + assert.ok(tx.ins.length >= 1, "Should have at least 1 input"); + }); + + it("should fail for unsigned inputs", function () { + const rootWalletKeys = getDefaultWalletKeys(); + + // Create unsigned PSBT (no signatures) + const psbt = BitGoPsbt.createEmpty("btc", rootWalletKeys, { + version: 2, + lockTime: 0, + }); + + // Add a p2sh input + 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) }); + + // Should fail because no signatures + assert.throws( + () => psbt.getHalfSignedLegacyFormat(), + /expected exactly 1 partial signature/i, + "Should throw for unsigned inputs", + ); + }); + }); + + describe("Comparison with utxo-lib extractP2msOnlyHalfSignedTx", function () { + for (const network of p2msNetworks) { + const networkName = utxolib.getNetworkName(network); + it(`${networkName}: should produce identical output to utxo-lib extractP2msOnlyHalfSignedTx`, function () { + const psbt = createHalfSignedP2msPsbt(network); + + // Get half-signed tx from wasm-utxo + const wasmHalfSignedTx = psbt.getHalfSignedLegacyFormat(); + + // Convert to utxo-lib PSBT and extract using reference implementation + const utxolibPsbt = toUtxolibPsbt(psbt, network); + const utxolibHalfSignedTx = utxolib.bitgo.extractP2msOnlyHalfSignedTx(utxolibPsbt); + const utxolibHalfSignedTxBytes = utxolibHalfSignedTx.toBuffer(); + + // Compare the results + assert.strictEqual( + Buffer.from(wasmHalfSignedTx).toString("hex"), + utxolibHalfSignedTxBytes.toString("hex"), + `Half-signed transaction should match utxo-lib output for ${networkName}`, + ); + }); + } + }); + + describe("Script type specific tests", function () { + it("should correctly place signature at position 0 (user key)", function () { + const psbt = createHalfSignedP2msPsbt(utxolib.networks.bitcoin); + + const halfSignedTx = psbt.getHalfSignedLegacyFormat(); + const tx = utxolib.bitgo.createTransactionFromBuffer( + Buffer.from(halfSignedTx), + utxolib.networks.bitcoin, + { amountType: "bigint" }, + ); + + // Verify each input has the signature in the correct position + for (let i = 0; i < tx.ins.length; i++) { + const input = tx.ins[i]; + + // For witness inputs, check witness array + if (input.witness && input.witness.length > 0) { + // Format: [empty, sig_or_empty, sig_or_empty, sig_or_empty, witnessScript] + assert.strictEqual( + input.witness[0].length, + 0, + `Input ${i}: First item should be empty (OP_0)`, + ); + // User key is at position 0, so signature should be at witness[1] + assert.ok( + input.witness[1].length > 0, + `Input ${i}: User signature should be at position 1`, + ); + assert.strictEqual(input.witness[2].length, 0, `Input ${i}: Position 2 should be empty`); + assert.strictEqual(input.witness[3].length, 0, `Input ${i}: Position 3 should be empty`); + } else { + // For non-witness (p2sh), check scriptSig + // Format: OP_0 + assert.ok(input.script.length > 0, `Input ${i}: Should have scriptSig`); + } + } + }); + }); + + describe("Error handling", function () { + it("should throw descriptive error for empty PSBT", function () { + const rootWalletKeys = getDefaultWalletKeys(); + const psbt = BitGoPsbt.createEmpty("btc", rootWalletKeys, { + version: 2, + lockTime: 0, + }); + + assert.throws( + () => psbt.getHalfSignedLegacyFormat(), + /empty inputs or outputs/i, + "Should throw for empty PSBT", + ); + }); + + it("should throw for inputs with 2 signatures", function () { + const rootWalletKeys = getDefaultWalletKeys(); + const xprvTriple = getKeyTriple("default"); + + // Create PSBT and sign with both user and bitgo keys + 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) }); + + // Sign with user key + psbt.sign(xprvTriple[0]); + // Sign with bitgo key + psbt.sign(xprvTriple[2]); + + // Should fail because inputs have 2 signatures + assert.throws( + () => psbt.getHalfSignedLegacyFormat(), + /expected exactly 1 partial signature/i, + "Should throw for fully signed inputs", + ); + }); + }); +}); diff --git a/packages/wasm-utxo/test/networks.ts b/packages/wasm-utxo/test/networks.ts index 4c05f960..bdfa1ccd 100644 --- a/packages/wasm-utxo/test/networks.ts +++ b/packages/wasm-utxo/test/networks.ts @@ -1,4 +1,5 @@ import type { CoinName } from "../js/coinName.js"; +import * as utxolib from "@bitgo/utxo-lib"; /** * Mainnet coin names for third-party fixtures. @@ -30,3 +31,50 @@ export function getNetworkName(coin: MainnetCoinName): string { export function isZcash(coin: MainnetCoinName): boolean { return coin === "zec"; } + +/** Convert utxolib network to CoinName */ +export function getCoinNameForNetwork(network: utxolib.Network): CoinName { + const name = utxolib.getNetworkName(network); + switch (name) { + case "bitcoin": + return "btc"; + case "testnet": + return "tbtc"; + case "bitcoinPublicSignet": + return "tbtcsig"; + case "bitcoinBitGoSignet": + return "tbtcbgsig"; + case "bitcoincash": + return "bch"; + case "bitcoincashTestnet": + return "tbch"; + case "ecash": + return "bcha"; + case "ecashTest": + return "tbcha"; + case "bitcoingold": + return "btg"; + case "bitcoingoldTestnet": + return "tbtg"; + case "bitcoinsv": + return "bsv"; + case "bitcoinsvTestnet": + return "tbsv"; + case "dash": + return "dash"; + case "dashTest": + return "tdash"; + case "dogecoin": + return "doge"; + case "dogecoinTest": + return "tdoge"; + case "litecoin": + return "ltc"; + case "litecoinTest": + return "tltc"; + case "zcash": + return "zec"; + case "zcashTest": + return "tzec"; + } +}