From 26ad785651ca03f4c374e147646696116a3648fc Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 29 Apr 2026 12:49:58 +0200 Subject: [PATCH 1/2] fix(wasm-utxo): handle replay protection inputs in PSBT reconstruction Allow PSBT reconstruction from half-signed BCH transactions that include unsigned replay protection inputs. Previously, inputs without scriptSig or witness data would error; now they parse as `Unsigned`. When hydrating from a legacy transaction, use the expected pubkey from unspents metadata if the input is unsigned, rather than requiring the pubkey to be extracted from the transaction. Add comprehensive regression tests for BCH FORKID sighash finalization and PSBT hydration scenarios. Tests verify that: - All partial sigs use hashType 0x41 (SIGHASH_ALL|SIGHASH_FORKID) - utxolib's finalizeAllInputs() succeeds without "Invalid hashType 0" - Both unsigned and signed p2shP2pk inputs reconstruct correctly - fromNetworkFormat preserves sigs in half-signed and fully-signed txs - Wallet-platform hydrate-and-cosign flow completes successfully Issue: BTC-2650 Co-authored-by: llm-git --- .../bitgo_psbt/fixed_script_input.rs | 16 +- .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 54 ++--- .../test/fixedScript/bchForkidFinalization.ts | 71 ++++++ .../test/fixedScript/bchHydrateAndFinalize.ts | 140 ++++++++++++ .../fromHalfSignedLegacyTransaction.ts | 202 ++++++++++++++---- 5 files changed, 420 insertions(+), 63 deletions(-) create mode 100644 packages/wasm-utxo/test/fixedScript/bchForkidFinalization.ts create mode 100644 packages/wasm-utxo/test/fixedScript/bchHydrateAndFinalize.ts diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/fixed_script_input.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/fixed_script_input.rs index 3ad3fb44cad..dc9464decec 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/fixed_script_input.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/fixed_script_input.rs @@ -27,6 +27,8 @@ pub(crate) enum FixedScriptInput { /// Raw sig bytes, or `None` if the slot is an OP_0 placeholder. sig_bytes: Option>, }, + /// Input with neither scriptSig nor witness — not yet signed. + Unsigned, } impl FixedScriptInput { @@ -67,7 +69,16 @@ impl FixedScriptInput { _ => return Err("Last scriptSig item is not a push".to_string()), }; let inner_script = ScriptBuf::from(redeem_bytes); - let slots = instructions[1..instructions.len() - 1] + // For multisig, scriptSig is OP_0 : + // index 0 is OP_0 (skip it), slots start at index 1. + // For P2SH-P2PK, scriptSig is : + // index 0 IS the sig, so slots must start at index 0. + let slot_start = if parse_p2pk_script(&inner_script).is_some() { + 0 + } else { + 1 + }; + let slots = instructions[slot_start..instructions.len() - 1] .iter() .map(|inst| match inst { miniscript::bitcoin::script::Instruction::PushBytes(b) => b.as_bytes().to_vec(), @@ -76,7 +87,7 @@ impl FixedScriptInput { .collect(); (inner_script, slots) } else { - return Err("Input has neither witness nor scriptSig".to_string()); + return Ok(Self::Unsigned); }; if parse_multisig_script_2_of_3(&inner_script).is_ok() { @@ -163,6 +174,7 @@ impl FixedScriptInput { .insert(PublicKey::from(*pubkey), sig); } } + Self::Unsigned => {} } Ok(()) } 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 b25bc4bac02..c5b68a70401 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 @@ -527,34 +527,38 @@ impl BitGoPsbt { pubkey: expected_pubkey, value, } => { - // Validate pubkey matches what's in the transaction let parsed = FixedScriptInput::from_txin(tx_in) .map_err(|e| format!("Input {}: {}", i, e))?; - if let FixedScriptInput::ReplayProtection { - pubkey: tx_pubkey, .. - } = &parsed - { - if tx_pubkey.to_bytes() != expected_pubkey.to_bytes() { - return Err(format!("Input {}: replay protection pubkey mismatch", i)); + let pubkey = match &parsed { + FixedScriptInput::ReplayProtection { + pubkey: tx_pubkey, .. + } => { + if tx_pubkey.to_bytes() != expected_pubkey.to_bytes() { + return Err(format!( + "Input {}: replay protection pubkey mismatch", + i + )); + } + *tx_pubkey } - Self::add_replay_protection_input_to_psbt( - psbt, - i, - network, - *tx_pubkey, - tx_in.previous_output.txid, - tx_in.previous_output.vout, - *value, - ReplayProtectionOptions { - sequence: Some(tx_in.sequence.0), - prev_tx: None, - sighash_type: None, - }, - ) - .map_err(|e| format!("Input {}: {}", i, e))?; - } else { - return Err(format!("Input {}: expected replay protection input", i)); - } + FixedScriptInput::Unsigned => *expected_pubkey, + _ => return Err(format!("Input {}: expected replay protection input", i)), + }; + Self::add_replay_protection_input_to_psbt( + psbt, + i, + network, + pubkey, + tx_in.previous_output.txid, + tx_in.previous_output.vout, + *value, + ReplayProtectionOptions { + sequence: Some(tx_in.sequence.0), + prev_tx: None, + sighash_type: None, + }, + ) + .map_err(|e| format!("Input {}: {}", i, e))?; } } } diff --git a/packages/wasm-utxo/test/fixedScript/bchForkidFinalization.ts b/packages/wasm-utxo/test/fixedScript/bchForkidFinalization.ts new file mode 100644 index 00000000000..8f771dcdce8 --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/bchForkidFinalization.ts @@ -0,0 +1,71 @@ +/** + * Regression test for BCH FORKID sighash in partial sigs. + * + * The wallet-platform reported: "Error: Invalid hashType 0" when calling + * utxolib's finalizeAllInputs() on a BCH PSBT after HSM co-signing. + * + * Root cause: an older version of wasm-utxo signed BCH p2shP2pk inputs with + * SIGHASH_ALL (0x01) instead of SIGHASH_ALL|SIGHASH_FORKID (0x41), or produced + * partial sigs without the hashType byte at all (last byte = 0x00). + * + * This test verifies that: + * 1. WASM-signed BCH PSBTs contain partial sigs with hashType 0x41. + * 2. utxolib's finalizeAllInputs() succeeds on such a PSBT (no "Invalid hashType" error). + */ + +import assert from "node:assert"; +import * as utxolib from "@bitgo/utxo-lib"; +import { loadPsbtFixture, getPsbtBuffer } from "./fixtureUtil.js"; + +const BCH_SIGHASH_FORKID = 0x41; // SIGHASH_ALL | SIGHASH_FORKID + +type ForkIdTestCase = { + coin: "bch" | "bcha"; + network: utxolib.Network; +}; + +const forkIdCoins: ForkIdTestCase[] = [ + { coin: "bch", network: utxolib.networks.bitcoincash }, + { coin: "bcha", network: utxolib.networks.ecash }, +]; + +describe("BCH/XEC FORKID finalization (regression: Invalid hashType 0)", function () { + for (const { coin, network } of forkIdCoins) { + for (const txFormat of ["psbt-lite", "psbt"] as const) { + describe(`coin: ${coin}, txFormat: ${txFormat}`, function () { + it("all partial sigs have hashType 0x41 (SIGHASH_ALL|SIGHASH_FORKID)", async function () { + const fixture = await loadPsbtFixture(coin, "fullsigned", txFormat); + const psbtBuffer = getPsbtBuffer(fixture); + const psbt = utxolib.bitgo.createPsbtFromBuffer(psbtBuffer, network); + + psbt.data.inputs.forEach((input, idx) => { + if (!input.partialSig || input.partialSig.length === 0) { + return; + } + input.partialSig.forEach((ps) => { + const hashTypeByte = ps.signature[ps.signature.length - 1]; + assert.strictEqual( + hashTypeByte, + BCH_SIGHASH_FORKID, + `input ${idx}: partial sig hashType byte is 0x${hashTypeByte.toString(16)}, expected 0x41 (SIGHASH_ALL|SIGHASH_FORKID)`, + ); + }); + }); + }); + + it("utxolib finalizeAllInputs() succeeds on WASM-signed PSBT", async function () { + const fixture = await loadPsbtFixture(coin, "fullsigned", txFormat); + const psbtBuffer = getPsbtBuffer(fixture); + const psbt = utxolib.bitgo.createPsbtFromBuffer(psbtBuffer, network); + + // This is where the wallet-platform error occurred: + // "Error: Invalid hashType 0 at checkPartialSigSighashes" + assert.doesNotThrow(() => psbt.finalizeAllInputs()); + + const tx = psbt.extractTransaction(); + assert.ok(tx); + }); + }); + } + } +}); diff --git a/packages/wasm-utxo/test/fixedScript/bchHydrateAndFinalize.ts b/packages/wasm-utxo/test/fixedScript/bchHydrateAndFinalize.ts new file mode 100644 index 00000000000..836f0207a99 --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/bchHydrateAndFinalize.ts @@ -0,0 +1,140 @@ +/** + * Regression test for the wallet-platform "Invalid hashType 0" error on BCH PSBTs. + * + * Root cause (found in fixed_script_input.rs::from_txin): + * For P2SH-P2PK scriptSig = , the sig is at instructions[0]. + * The original slot range instructions[1..len-1] (designed for multisig OP_0 prefix) + * produced an empty slice, so sig_bytes was always None. The p2shP2pk partial sig + * was lost during fromNetworkFormat hydration. + * + * Fix: detect P2PK redeemScript and start slots at index 0 for p2shP2pk inputs. + * + * The wallet-platform reported "Invalid hashType 0" at finalizeAllInputs() because + * the p2shP2pk partial sig was absent from the hydrated PSBT, leaving no valid sig + * for finalization. With a later combine step, a partial sig with unexpected bytes + * could produce the literal "Invalid hashType 0" error; the core cause is the lost sig. + */ + +import assert from "node:assert"; +import * as utxolib from "@bitgo/utxo-lib"; +import { AcidTest } from "../../js/testutils/AcidTest.js"; +import { BitGoPsbt } from "../../js/fixedScriptWallet/BitGoPsbt.js"; +import { ECPair } from "../../js/ecpair.js"; +import type { HydrationUnspent } from "../../js/fixedScriptWallet/BitGoPsbt.js"; + +function buildHydrationUnspents(acid: AcidTest): HydrationUnspent[] { + const rpPubkey = acid.userXprv.publicKey; + return acid.inputs.map((input, i) => { + if ("scriptType" in input && input.scriptType === "p2shP2pk") { + return { pubkey: rpPubkey, value: input.value }; + } + const scriptId = + "scriptId" in input && input.scriptId ? input.scriptId : { chain: 0, index: i }; + return { chain: scriptId.chain, index: scriptId.index, value: input.value }; + }); +} + +describe("BCH p2shP2pk hydration (regression: 'Invalid hashType 0' / sig lost in fromNetworkFormat)", function () { + for (const txFormat of ["psbt-lite", "psbt"] as const) { + describe(`txFormat: ${txFormat}`, function () { + it("p2shP2pk sig preserved after fromNetworkFormat on half-signed tx", function () { + const acid = AcidTest.withConfig("bch", "halfsigned", txFormat); + const rpECPair = ECPair.fromPrivateKey(Buffer.from(acid.userXprv.privateKey)); + const rpIdx = acid.inputs.findIndex( + (i) => "scriptType" in i && i.scriptType === "p2shP2pk", + ); + assert.ok(rpIdx >= 0, "AcidTest should include a p2shP2pk input for BCH"); + + const halfsignedPsbt = acid.createPsbt(); // user+rp signed + const legacyBytes = halfsignedPsbt.getHalfSignedLegacyFormat(); + + const unspents = buildHydrationUnspents(acid); + const hydratedPsbt = BitGoPsbt.fromNetworkFormat( + legacyBytes, + "bch", + acid.rootWalletKeys, + unspents, + ); + + // The p2shP2pk partial sig must survive the round-trip + assert.ok( + hydratedPsbt.verifySignature(rpIdx, rpECPair), + "p2shP2pk partial sig should be preserved after fromNetworkFormat", + ); + }); + + it("p2shP2pk sig preserved after fromNetworkFormat on fully-signed tx", function () { + const acid = AcidTest.withConfig("bch", "fullsigned", txFormat); + const rpECPair = ECPair.fromPrivateKey(Buffer.from(acid.userXprv.privateKey)); + const rpIdx = acid.inputs.findIndex( + (i) => "scriptType" in i && i.scriptType === "p2shP2pk", + ); + assert.ok(rpIdx >= 0, "AcidTest should include a p2shP2pk input for BCH"); + + const fullsignedPsbt = acid.createPsbt(); + fullsignedPsbt.finalizeAllInputs(); + const txBytes = fullsignedPsbt.extractTransaction().toBytes(); + + const unspents = buildHydrationUnspents(acid); + const hydratedPsbt = BitGoPsbt.fromNetworkFormat( + txBytes, + "bch", + acid.rootWalletKeys, + unspents, + ); + + // The p2shP2pk partial sig must survive hydration from a fully-signed tx + assert.ok( + hydratedPsbt.verifySignature(rpIdx, rpECPair), + "p2shP2pk partial sig should be preserved after fromNetworkFormat from fully-signed tx", + ); + }); + + it("finalization succeeds via utxolib after hydrate-and-cosign (wallet-platform scenario)", function () { + const acid = AcidTest.withConfig("bch", "halfsigned", txFormat); + const rpECPair = ECPair.fromPrivateKey(Buffer.from(acid.userXprv.privateKey)); + const rpIdx = acid.inputs.findIndex( + (i) => "scriptType" in i && i.scriptType === "p2shP2pk", + ); + + // 1. User signs wallet inputs → half-signed legacy tx + const legacyBytes = acid.createPsbt().getHalfSignedLegacyFormat(); + + // 2. indexerdb hydrates (fromNetworkFormat) + const unspents = buildHydrationUnspents(acid); + const hydratedPsbt = BitGoPsbt.fromNetworkFormat( + legacyBytes, + "bch", + acid.rootWalletKeys, + unspents, + ); + + // 3. HSM co-signs wallet inputs + p2shP2pk + hydratedPsbt.sign(acid.bitgoXprv); + if (rpIdx >= 0) hydratedPsbt.signInput(rpIdx, rpECPair); + + // 4. wallet-platform finalizes via utxolib + const utxolibPsbt = utxolib.bitgo.createPsbtFromBuffer( + Buffer.from(hydratedPsbt.serialize()), + utxolib.networks.bitcoincash, + ); + + // All partial sigs must have hashType 0x41 (SIGHASH_ALL | SIGHASH_FORKID) + utxolibPsbt.data.inputs.forEach((input, idx) => { + (input.partialSig ?? []).forEach((ps) => { + const hashTypeByte = ps.signature[ps.signature.length - 1]; + assert.strictEqual( + hashTypeByte, + 0x41, + `input ${idx}: partial sig hashType = 0x${hashTypeByte.toString(16)}, expected 0x41`, + ); + }); + }); + + // This is the line that threw "Invalid hashType 0" on the wallet-platform + assert.doesNotThrow(() => utxolibPsbt.finalizeAllInputs()); + assert.ok(utxolibPsbt.extractTransaction()); + }); + }); + } +}); diff --git a/packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts b/packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts index 18c56aae0d4..ece52b7763a 100644 --- a/packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts +++ b/packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts @@ -126,46 +126,88 @@ describe("BitGoPsbt.fromHalfSignedLegacyTransaction", function () { }); describe("Round-trip with replay protection input", function () { - it("reconstructs PSBT from legacy tx with wallet + replay protection input", function () { - const rootWalletKeys = getDefaultWalletKeys(); - const [userXprv] = getKeyTriple("default"); - const ecpair = ECPair.fromPublicKey(rootWalletKeys.userKey().publicKey); + type RpTestCase = { coin: CoinName; desc: string }; + const rpCoins: RpTestCase[] = [ + { coin: "btc", desc: "BTC (standard sighash)" }, + { coin: "bch", desc: "BCH (SIGHASH_ALL|SIGHASH_FORKID = 0x41)" }, + ]; + + for (const { coin, desc } of rpCoins) { + it(`${desc}: unsigned p2shP2pk — wallet sig preserved, p2shP2pk has no sig`, function () { + const rootWalletKeys = getDefaultWalletKeys(); + const [userXprv] = getKeyTriple("default"); + 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) }); - // sign() only signs wallet inputs; replay protection input gets 0 sigs - psbt.sign(userXprv); + const psbt = BitGoPsbt.createEmpty(coin, 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) }); + psbt.sign(userXprv); // only signs wallet inputs - const txBytes = psbt.getHalfSignedLegacyFormat(); - const tx = Transaction.fromBytes(txBytes, "btc"); + const txBytes = psbt.getHalfSignedLegacyFormat(); + const tx = Transaction.fromBytes(txBytes, coin); + const unspents: HydrationUnspent[] = [ + { chain: 0, index: 0, value: BigInt(10000) }, + { pubkey: ecpair.publicKey, value: BigInt(1000) }, + ]; + const reconstructed = BitGoPsbt.fromHalfSignedLegacyTransaction(tx, coin, rootWalletKeys, unspents); + + assert.strictEqual(reconstructed.inputCount(), 2); + assert.ok( + reconstructed.verifySignature(0, rootWalletKeys.userKey().neutered().toBase58()), + "Wallet sig preserved", + ); + assert.ok( + !reconstructed.verifySignature(1, ecpair), + "p2shP2pk has no sig (unsigned)", + ); + }); - const unspents: HydrationUnspent[] = [ - { chain: 0, index: 0, value: BigInt(10000) }, // wallet - { pubkey: ecpair.publicKey, value: BigInt(1000) }, // replay protection - ]; - const reconstructed = BitGoPsbt.fromHalfSignedLegacyTransaction( - tx, - "btc", - rootWalletKeys, - unspents, - ); + it(`${desc}: signed p2shP2pk — both wallet and p2shP2pk sigs preserved`, function () { + const rootWalletKeys = getDefaultWalletKeys(); + const [userXprv] = getKeyTriple("default"); + const ecpair = ECPair.fromPrivateKey(Buffer.from(userXprv.privateKey!)); - assert.ok(reconstructed.serialize().length > 0, "Reconstructed PSBT serializes"); - assert.strictEqual(reconstructed.inputCount(), 2, "Both inputs present"); - assert.ok( - reconstructed.verifySignature(0, rootWalletKeys.userKey().neutered().toBase58()), - "Wallet input signature preserved", - ); - }); + const psbt = BitGoPsbt.createEmpty(coin, 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) }); + psbt.sign(userXprv); + psbt.signInput(1, ecpair); // sign p2shP2pk + + const txBytes = psbt.getHalfSignedLegacyFormat(); + const tx = Transaction.fromBytes(txBytes, coin); + const unspents: HydrationUnspent[] = [ + { chain: 0, index: 0, value: BigInt(10000) }, + { pubkey: ecpair.publicKey, value: BigInt(1000) }, + ]; + const reconstructed = BitGoPsbt.fromHalfSignedLegacyTransaction(tx, coin, rootWalletKeys, unspents); + + assert.strictEqual(reconstructed.inputCount(), 2); + assert.ok( + reconstructed.verifySignature(0, rootWalletKeys.userKey().neutered().toBase58()), + "Wallet sig preserved", + ); + assert.ok( + reconstructed.verifySignature(1, ecpair), + "p2shP2pk sig preserved after round-trip", + ); + }); + } }); describe("Full-signed transaction", function () { @@ -359,6 +401,94 @@ describe("BitGoPsbt.fromNetworkFormat", function () { } }); + describe("with replay protection input", function () { + type RpTestCase = { coin: CoinName; desc: string }; + const rpCoins: RpTestCase[] = [ + { coin: "btc", desc: "BTC" }, + { coin: "bch", desc: "BCH (SIGHASH_FORKID)" }, + ]; + + for (const { coin, desc } of rpCoins) { + it(`${desc}: half-signed — wallet and p2shP2pk sigs preserved`, function () { + const rootWalletKeys = getDefaultWalletKeys(); + const ecpair = ECPair.fromPrivateKey(Buffer.from(userXprv.privateKey!)); + + const psbt = BitGoPsbt.createEmpty(coin, 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) }); + psbt.sign(userXprv); + psbt.signInput(1, ecpair); + + const txBytes = psbt.getHalfSignedLegacyFormat(); + const unspents: HydrationUnspent[] = [ + { chain: 0, index: 0, value: BigInt(10000) }, + { pubkey: ecpair.publicKey, value: BigInt(1000) }, + ]; + const reconstructed = BitGoPsbt.fromNetworkFormat(txBytes, coin, rootWalletKeys, unspents); + + assert.strictEqual(reconstructed.inputCount(), 2); + assert.ok( + reconstructed.verifySignature(0, userXprv.neutered().toBase58()), + "Wallet sig preserved", + ); + assert.ok( + reconstructed.verifySignature(1, ecpair), + "p2shP2pk sig preserved", + ); + }); + + it(`${desc}: full-signed — both wallet sigs and p2shP2pk sig preserved`, function () { + const rootWalletKeys = getDefaultWalletKeys(); + const ecpair = ECPair.fromPrivateKey(Buffer.from(userXprv.privateKey!)); + + const psbt = BitGoPsbt.createEmpty(coin, 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) }); + psbt.sign(userXprv); + psbt.sign(bitgoXprv); + psbt.signInput(1, ecpair); + psbt.finalizeAllInputs(); + const txBytes = psbt.extractTransaction().toBytes(); + + const unspents: HydrationUnspent[] = [ + { chain: 0, index: 0, value: BigInt(10000) }, + { pubkey: ecpair.publicKey, value: BigInt(1000) }, + ]; + const reconstructed = BitGoPsbt.fromNetworkFormat(txBytes, coin, rootWalletKeys, unspents); + + assert.strictEqual(reconstructed.inputCount(), 2); + assert.ok( + reconstructed.verifySignature(0, userXprv.neutered().toBase58()), + "User wallet sig preserved", + ); + assert.ok( + reconstructed.verifySignature(0, bitgoXprv.neutered().toBase58()), + "BitGo wallet sig preserved", + ); + assert.ok( + reconstructed.verifySignature(1, ecpair), + "p2shP2pk sig preserved from fully-signed tx", + ); + }); + } + }); + describe("ZcashBitGoPsbt.fromNetworkFormat", function () { it("zec half-signed: PSBT has user signature (blockHeight)", function () { const rootWalletKeys = getDefaultWalletKeys(); From cb86a547aeeee75124fe34851857c0851a8da43c Mon Sep 17 00:00:00 2001 From: Otto Allmendinger Date: Wed, 29 Apr 2026 14:46:31 +0200 Subject: [PATCH 2/2] test(wasm-utxo): remove deprecated fromHalfSignedLegacyTransaction tests Remove tests for deprecated `fromHalfSignedLegacyTransaction` method. Retain tests for `fromNetworkFormat` which supersedes it. Consolidate test file and reduce redundancy by combining half-signed and full-signed test cases into a single parameterized helper. Issue: BTC-2650 Co-authored-by: llm-git --- .../fromHalfSignedLegacyTransaction.ts | 575 ------------------ .../test/fixedScript/fromNetworkFormat.ts | 242 ++++++++ 2 files changed, 242 insertions(+), 575 deletions(-) delete mode 100644 packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts create mode 100644 packages/wasm-utxo/test/fixedScript/fromNetworkFormat.ts diff --git a/packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts b/packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts deleted file mode 100644 index ece52b7763a..00000000000 --- a/packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts +++ /dev/null @@ -1,575 +0,0 @@ -/** - * Tests for BitGoPsbt.fromHalfSignedLegacyTransaction() and BitGoPsbt.fromNetworkFormat(). - * - * fromHalfSignedLegacyTransaction is deprecated in favour of fromNetworkFormat. This file - * tests both the deprecated path (to verify it keeps working) and the new path. - */ -import { describe, it } from "mocha"; -import * as assert from "assert"; -import { BitGoPsbt, type HydrationUnspent } from "../../js/fixedScriptWallet/BitGoPsbt.js"; -import { ZcashBitGoPsbt } from "../../js/fixedScriptWallet/ZcashBitGoPsbt.js"; -import { supportsScriptType } from "../../js/fixedScriptWallet/index.js"; -import { ChainCode } from "../../js/fixedScriptWallet/chains.js"; -import { ECPair } from "../../js/ecpair.js"; -import { Transaction, ZcashTransaction } from "../../js/transaction.js"; -import { coinNames, type CoinName, isMainnet } from "../../js/coinName.js"; -import { getDefaultWalletKeys, getKeyTriple } from "../../js/testutils/keys.js"; - -const ZCASH_NU5_HEIGHT = 1687105; - -const p2msScriptTypes = ["p2sh", "p2shP2wsh", "p2wsh"] as const; - -// Coins excluded from round-trip tests (use special handling or not supported) -const EXCLUDED_COINS: CoinName[] = ["bsv", "bcha", "zec"]; - -function isSupportedCoin(coin: CoinName): boolean { - return isMainnet(coin) && !EXCLUDED_COINS.includes(coin); -} - -function createHalfSignedP2msPsbt( - coinName: CoinName, - valueOverride?: bigint, -): { psbt: BitGoPsbt; unspents: HydrationUnspent[] } { - const rootWalletKeys = getDefaultWalletKeys(); - const [userXprv] = getKeyTriple("default"); - - const supportedTypes = p2msScriptTypes.filter((scriptType) => - supportsScriptType(coinName, scriptType), - ); - - const isZcash = coinName === "zec" || coinName === "tzec"; - const psbt = isZcash - ? ZcashBitGoPsbt.createEmpty(coinName, rootWalletKeys, { - version: 4, - lockTime: 0, - blockHeight: ZCASH_NU5_HEIGHT, - }) - : BitGoPsbt.createEmpty(coinName, rootWalletKeys, { version: 2, lockTime: 0 }); - - const unspents: HydrationUnspent[] = []; - supportedTypes.forEach((scriptType, index) => { - const chain = ChainCode.value(scriptType, "external"); - const value = valueOverride ?? BigInt(10000 + index * 10000); - psbt.addWalletInput( - { - txid: `${"00".repeat(31)}${index.toString(16).padStart(2, "0")}`, - vout: 0, - value, - sequence: 0xfffffffd, - }, - rootWalletKeys, - { scriptId: { chain, index } }, - ); - unspents.push({ chain, index, value }); - }); - - psbt.addWalletOutput(rootWalletKeys, { chain: 0, index: 100, value: BigInt(5000) }); - psbt.sign(userXprv); - - return { psbt, unspents }; -} - -describe("BitGoPsbt.fromHalfSignedLegacyTransaction", function () { - describe("BigInt value conversion (regression for unchecked-from/as_f64 bug)", function () { - it("should not throw when unspent values are JS BigInt", function () { - // With the buggy Rust code this always threw "'value' must be a bigint" - // because BigInt::from(value_js).as_f64() calls JsValue::as_f64(), which - // returns None for JS BigInt (it only works for JS Number). - const rootWalletKeys = getDefaultWalletKeys(); - const { psbt, unspents } = createHalfSignedP2msPsbt("btc"); - const txBytes = psbt.getHalfSignedLegacyFormat(); - const tx = Transaction.fromBytes(txBytes, "btc"); - - assert.doesNotThrow(() => { - BitGoPsbt.fromHalfSignedLegacyTransaction(tx, "btc", rootWalletKeys, unspents); - }, "fromHalfSignedLegacyTransaction must not throw for valid JS BigInt values"); - }); - - it("should handle values larger than Number.MAX_SAFE_INTEGER", function () { - // Values beyond 2^53-1 would silently lose precision through f64; the fixed - // code converts directly via u64::try_from so precision is preserved. - const rootWalletKeys = getDefaultWalletKeys(); - // 21 million BTC in satoshis — the maximum possible UTXO value - const maxSats = 21_000_000n * 100_000_000n; - const { psbt, unspents } = createHalfSignedP2msPsbt("btc", maxSats); - const txBytes = psbt.getHalfSignedLegacyFormat(); - const tx = Transaction.fromBytes(txBytes, "btc"); - - assert.doesNotThrow(() => { - BitGoPsbt.fromHalfSignedLegacyTransaction(tx, "btc", rootWalletKeys, unspents); - }, "fromHalfSignedLegacyTransaction must handle large satoshi values"); - }); - }); - - describe("Round-trip: getHalfSignedLegacyFormat → fromHalfSignedLegacyTransaction", function () { - // Supported coins for round-trip: all mainnet UTXO coins except special formats - const roundTripCoins = coinNames.filter(isSupportedCoin); - - for (const coinName of roundTripCoins) { - it(`${coinName}: reconstructed PSBT serializes without error`, function () { - const rootWalletKeys = getDefaultWalletKeys(); - const { psbt, unspents } = createHalfSignedP2msPsbt(coinName); - const txBytes = psbt.getHalfSignedLegacyFormat(); - const tx = Transaction.fromBytes(txBytes, coinName); - - const reconstructed = BitGoPsbt.fromHalfSignedLegacyTransaction( - tx, - coinName, - rootWalletKeys, - unspents, - ); - - const serialized = reconstructed.serialize(); - assert.ok(serialized.length > 0, "Reconstructed PSBT should serialize to non-empty bytes"); - }); - } - }); - - describe("Round-trip with replay protection input", function () { - type RpTestCase = { coin: CoinName; desc: string }; - const rpCoins: RpTestCase[] = [ - { coin: "btc", desc: "BTC (standard sighash)" }, - { coin: "bch", desc: "BCH (SIGHASH_ALL|SIGHASH_FORKID = 0x41)" }, - ]; - - for (const { coin, desc } of rpCoins) { - it(`${desc}: unsigned p2shP2pk — wallet sig preserved, p2shP2pk has no sig`, function () { - const rootWalletKeys = getDefaultWalletKeys(); - const [userXprv] = getKeyTriple("default"); - const ecpair = ECPair.fromPublicKey(rootWalletKeys.userKey().publicKey); - - const psbt = BitGoPsbt.createEmpty(coin, 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) }); - psbt.sign(userXprv); // only signs wallet inputs - - const txBytes = psbt.getHalfSignedLegacyFormat(); - const tx = Transaction.fromBytes(txBytes, coin); - const unspents: HydrationUnspent[] = [ - { chain: 0, index: 0, value: BigInt(10000) }, - { pubkey: ecpair.publicKey, value: BigInt(1000) }, - ]; - const reconstructed = BitGoPsbt.fromHalfSignedLegacyTransaction(tx, coin, rootWalletKeys, unspents); - - assert.strictEqual(reconstructed.inputCount(), 2); - assert.ok( - reconstructed.verifySignature(0, rootWalletKeys.userKey().neutered().toBase58()), - "Wallet sig preserved", - ); - assert.ok( - !reconstructed.verifySignature(1, ecpair), - "p2shP2pk has no sig (unsigned)", - ); - }); - - it(`${desc}: signed p2shP2pk — both wallet and p2shP2pk sigs preserved`, function () { - const rootWalletKeys = getDefaultWalletKeys(); - const [userXprv] = getKeyTriple("default"); - const ecpair = ECPair.fromPrivateKey(Buffer.from(userXprv.privateKey!)); - - const psbt = BitGoPsbt.createEmpty(coin, 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) }); - psbt.sign(userXprv); - psbt.signInput(1, ecpair); // sign p2shP2pk - - const txBytes = psbt.getHalfSignedLegacyFormat(); - const tx = Transaction.fromBytes(txBytes, coin); - const unspents: HydrationUnspent[] = [ - { chain: 0, index: 0, value: BigInt(10000) }, - { pubkey: ecpair.publicKey, value: BigInt(1000) }, - ]; - const reconstructed = BitGoPsbt.fromHalfSignedLegacyTransaction(tx, coin, rootWalletKeys, unspents); - - assert.strictEqual(reconstructed.inputCount(), 2); - assert.ok( - reconstructed.verifySignature(0, rootWalletKeys.userKey().neutered().toBase58()), - "Wallet sig preserved", - ); - assert.ok( - reconstructed.verifySignature(1, ecpair), - "p2shP2pk sig preserved after round-trip", - ); - }); - } - }); - - describe("Full-signed transaction", function () { - function createFullSignedTxBytes(coinName: CoinName): { - txBytes: Uint8Array; - unspents: HydrationUnspent[]; - } { - const [, , bitgoXprv] = getKeyTriple("default"); - const { psbt, unspents } = createHalfSignedP2msPsbt(coinName); - psbt.sign(bitgoXprv); - psbt.finalizeAllInputs(); - return { txBytes: psbt.extractTransaction().toBytes(), unspents }; - } - - const fullSignedCoins = coinNames.filter(isSupportedCoin); - - for (const coinName of fullSignedCoins) { - it(`${coinName}: throws because fully-signed transaction has 2 signatures`, function () { - const rootWalletKeys = getDefaultWalletKeys(); - const { txBytes, unspents } = createFullSignedTxBytes(coinName); - assert.throws( - () => - BitGoPsbt.fromHalfSignedLegacyTransaction(txBytes, coinName, rootWalletKeys, unspents), - /expected 1 signature for half-signed transaction, found 2/i, - ); - }); - } - }); - - describe("Zcash legacy format round-trip", function () { - it("should reject Zcash via type check in fromHalfSignedLegacyTransaction", function () { - // fromHalfSignedLegacyTransaction validates the transaction type at call time - // and rejects Zcash with a clear error message. - const rootWalletKeys = getDefaultWalletKeys(); - const { psbt: zcashPsbt, unspents } = createHalfSignedP2msPsbt("zec"); - - // Step 1: Extract Zcash PSBT as legacy format - const txBytes = zcashPsbt.getHalfSignedLegacyFormat(); - assert.ok(txBytes.length > 0, "ZcashBitGoPsbt.getHalfSignedLegacyFormat() produces bytes"); - - // Step 2: Parse the transaction (will be ZcashTransaction) - const tx = Transaction.fromBytes(txBytes, "zec"); - assert.ok(tx instanceof ZcashTransaction, "Parsed transaction is ZcashTransaction"); - - // Step 3: Call fromHalfSignedLegacyTransaction with Zcash transaction - // Expected: Throws clear error after detecting Zcash transaction - assert.throws(() => { - BitGoPsbt.fromHalfSignedLegacyTransaction(tx, "zec", rootWalletKeys, unspents); - }, /Use ZcashBitGoPsbt.fromHalfSignedLegacyTransaction\(\) for Zcash transactions/); - }); - - it("should round-trip Zcash PSBT via ZcashBitGoPsbt.fromHalfSignedLegacyTransaction (with blockHeight)", function () { - // This test verifies the round-trip: create Zcash PSBT → extract legacy format → reconstruct PSBT - const rootWalletKeys = getDefaultWalletKeys(); - const { psbt, unspents } = createHalfSignedP2msPsbt("zec"); - - // Step 1: Extract half-signed legacy format (this is what would be transmitted) - const legacyBytes = psbt.getHalfSignedLegacyFormat(); - assert.ok(legacyBytes.length > 0, "getHalfSignedLegacyFormat() produces bytes"); - - // Step 2: Reconstruct PSBT from legacy format with block height - const reconstructed = ZcashBitGoPsbt.fromHalfSignedLegacyTransaction( - legacyBytes, - "zec", - rootWalletKeys, - unspents, - { blockHeight: ZCASH_NU5_HEIGHT }, - ); - - // Step 3: Verify reconstruction succeeded - assert.ok(reconstructed, "fromHalfSignedLegacyTransaction() reconstructs PSBT"); - assert.ok(reconstructed instanceof ZcashBitGoPsbt, "Reconstructed PSBT is ZcashBitGoPsbt"); - - // Step 4: Verify Zcash metadata is preserved - assert.strictEqual(reconstructed.version(), 4, "Zcash version preserved as 4 (Overwintered)"); - - // Step 5: Verify serialization works (round-trip complete) - const serialized = reconstructed.serialize(); - assert.ok(serialized.length > 0, "Reconstructed Zcash PSBT serializes without error"); - }); - - it("should round-trip Zcash PSBT via ZcashBitGoPsbt.fromHalfSignedLegacyTransaction (with consensusBranchId)", function () { - // This test verifies the round-trip with explicit consensus branch ID instead of block height - const rootWalletKeys = getDefaultWalletKeys(); - const { psbt, unspents } = createHalfSignedP2msPsbt("zec"); - - // Step 1: Extract half-signed legacy format - const legacyBytes = psbt.getHalfSignedLegacyFormat(); - - // Step 2: Reconstruct PSBT from legacy format with explicit consensus branch ID - // 0xC2D6D0B4 is the NU5 consensus branch ID - const reconstructed = ZcashBitGoPsbt.fromHalfSignedLegacyTransaction( - legacyBytes, - "zec", - rootWalletKeys, - unspents, - { consensusBranchId: 0xc2d6d0b4 }, - ); - - // Step 3: Verify reconstruction succeeded with explicit branch ID - assert.ok( - reconstructed, - "fromHalfSignedLegacyTransactionZcash() works with consensusBranchId", - ); - assert.ok(reconstructed instanceof ZcashBitGoPsbt, "Reconstructed PSBT is ZcashBitGoPsbt"); - - // Step 4: Verify serialization works - const serialized = reconstructed.serialize(); - assert.ok(serialized.length > 0, "Reconstructed Zcash PSBT serializes without error"); - }); - - it("should accept pre-decoded transaction instance", function () { - // fromHalfSignedLegacyTransaction accepts a pre-decoded Transaction instance. - // This is more efficient than parsing bytes twice. - const rootWalletKeys = getDefaultWalletKeys(); - const { psbt, unspents } = createHalfSignedP2msPsbt("btc"); - const txBytes = psbt.getHalfSignedLegacyFormat(); - - // Parse transaction once and pass the instance - const tx = Transaction.fromBytes(txBytes, "btc"); - const psbt1 = BitGoPsbt.fromHalfSignedLegacyTransaction(tx, "btc", rootWalletKeys, unspents); - - // Parse again to compare - const tx2 = Transaction.fromBytes(txBytes, "btc"); - const psbt2 = BitGoPsbt.fromHalfSignedLegacyTransaction(tx2, "btc", rootWalletKeys, unspents); - - // Both should produce equivalent results - assert.strictEqual(psbt1.inputCount(), psbt2.inputCount(), "Same input count"); - assert.strictEqual(psbt1.outputCount(), psbt2.outputCount(), "Same output count"); - assert.deepStrictEqual(psbt1.serialize(), psbt2.serialize(), "Identical serialization"); - }); - }); -}); - -describe("BitGoPsbt.fromNetworkFormat", function () { - const [userXprv, , bitgoXprv] = getKeyTriple("default"); - - describe("Half-signed input", function () { - const roundTripCoins = coinNames.filter(isSupportedCoin); - - for (const coinName of roundTripCoins) { - it(`${coinName}: succeeds and PSBT has user signature`, function () { - const rootWalletKeys = getDefaultWalletKeys(); - const { psbt, unspents } = createHalfSignedP2msPsbt(coinName); - const txBytes = psbt.getHalfSignedLegacyFormat(); - - const reconstructed = BitGoPsbt.fromNetworkFormat( - txBytes, - coinName, - rootWalletKeys, - unspents, - ); - - assert.ok(reconstructed.serialize().length > 0, "PSBT serializes"); - assert.ok( - reconstructed.verifySignature(0, userXprv.neutered().toBase58()), - "User signature present in input 0", - ); - }); - } - }); - - describe("Full-signed input", function () { - const fullSignedCoins = coinNames.filter(isSupportedCoin); - - for (const coinName of fullSignedCoins) { - it(`${coinName}: succeeds and PSBT has both user and bitgo signatures`, function () { - const rootWalletKeys = getDefaultWalletKeys(); - const { psbt, unspents } = createHalfSignedP2msPsbt(coinName); - psbt.sign(bitgoXprv); - psbt.finalizeAllInputs(); - const txBytes = psbt.extractTransaction().toBytes(); - - const reconstructed = BitGoPsbt.fromNetworkFormat( - txBytes, - coinName, - rootWalletKeys, - unspents, - ); - - assert.ok(reconstructed.serialize().length > 0, "PSBT serializes"); - assert.ok( - reconstructed.verifySignature(0, userXprv.neutered().toBase58()), - "User signature present in input 0", - ); - assert.ok( - reconstructed.verifySignature(0, bitgoXprv.neutered().toBase58()), - "Bitgo signature present in input 0", - ); - }); - } - }); - - describe("with replay protection input", function () { - type RpTestCase = { coin: CoinName; desc: string }; - const rpCoins: RpTestCase[] = [ - { coin: "btc", desc: "BTC" }, - { coin: "bch", desc: "BCH (SIGHASH_FORKID)" }, - ]; - - for (const { coin, desc } of rpCoins) { - it(`${desc}: half-signed — wallet and p2shP2pk sigs preserved`, function () { - const rootWalletKeys = getDefaultWalletKeys(); - const ecpair = ECPair.fromPrivateKey(Buffer.from(userXprv.privateKey!)); - - const psbt = BitGoPsbt.createEmpty(coin, 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) }); - psbt.sign(userXprv); - psbt.signInput(1, ecpair); - - const txBytes = psbt.getHalfSignedLegacyFormat(); - const unspents: HydrationUnspent[] = [ - { chain: 0, index: 0, value: BigInt(10000) }, - { pubkey: ecpair.publicKey, value: BigInt(1000) }, - ]; - const reconstructed = BitGoPsbt.fromNetworkFormat(txBytes, coin, rootWalletKeys, unspents); - - assert.strictEqual(reconstructed.inputCount(), 2); - assert.ok( - reconstructed.verifySignature(0, userXprv.neutered().toBase58()), - "Wallet sig preserved", - ); - assert.ok( - reconstructed.verifySignature(1, ecpair), - "p2shP2pk sig preserved", - ); - }); - - it(`${desc}: full-signed — both wallet sigs and p2shP2pk sig preserved`, function () { - const rootWalletKeys = getDefaultWalletKeys(); - const ecpair = ECPair.fromPrivateKey(Buffer.from(userXprv.privateKey!)); - - const psbt = BitGoPsbt.createEmpty(coin, 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) }); - psbt.sign(userXprv); - psbt.sign(bitgoXprv); - psbt.signInput(1, ecpair); - psbt.finalizeAllInputs(); - const txBytes = psbt.extractTransaction().toBytes(); - - const unspents: HydrationUnspent[] = [ - { chain: 0, index: 0, value: BigInt(10000) }, - { pubkey: ecpair.publicKey, value: BigInt(1000) }, - ]; - const reconstructed = BitGoPsbt.fromNetworkFormat(txBytes, coin, rootWalletKeys, unspents); - - assert.strictEqual(reconstructed.inputCount(), 2); - assert.ok( - reconstructed.verifySignature(0, userXprv.neutered().toBase58()), - "User wallet sig preserved", - ); - assert.ok( - reconstructed.verifySignature(0, bitgoXprv.neutered().toBase58()), - "BitGo wallet sig preserved", - ); - assert.ok( - reconstructed.verifySignature(1, ecpair), - "p2shP2pk sig preserved from fully-signed tx", - ); - }); - } - }); - - describe("ZcashBitGoPsbt.fromNetworkFormat", function () { - it("zec half-signed: PSBT has user signature (blockHeight)", function () { - const rootWalletKeys = getDefaultWalletKeys(); - const { psbt, unspents } = createHalfSignedP2msPsbt("zec"); - const txBytes = psbt.getHalfSignedLegacyFormat(); - - const reconstructed = ZcashBitGoPsbt.fromNetworkFormat( - txBytes, - "zec", - rootWalletKeys, - unspents, - { blockHeight: ZCASH_NU5_HEIGHT }, - ); - - assert.ok(reconstructed instanceof ZcashBitGoPsbt, "Returns ZcashBitGoPsbt"); - assert.ok(reconstructed.serialize().length > 0, "PSBT serializes"); - assert.ok( - reconstructed.verifySignature(0, userXprv.neutered().toBase58()), - "User signature present in input 0", - ); - }); - - it("zec half-signed: PSBT has user signature (consensusBranchId)", function () { - const rootWalletKeys = getDefaultWalletKeys(); - const { psbt, unspents } = createHalfSignedP2msPsbt("zec"); - const txBytes = psbt.getHalfSignedLegacyFormat(); - - const reconstructed = ZcashBitGoPsbt.fromNetworkFormat( - txBytes, - "zec", - rootWalletKeys, - unspents, - { consensusBranchId: 0xc2d6d0b4 }, - ); - - assert.ok(reconstructed instanceof ZcashBitGoPsbt, "Returns ZcashBitGoPsbt"); - assert.ok(reconstructed.serialize().length > 0, "PSBT serializes"); - assert.ok( - reconstructed.verifySignature(0, userXprv.neutered().toBase58()), - "User signature present in input 0", - ); - }); - - it("zec full-signed: succeeds and PSBT has both user and bitgo signatures", function () { - const rootWalletKeys = getDefaultWalletKeys(); - const { psbt, unspents } = createHalfSignedP2msPsbt("zec"); - psbt.sign(bitgoXprv); - psbt.finalizeAllInputs(); - const txBytes = psbt.extractTransaction().toBytes(); - - const reconstructed = ZcashBitGoPsbt.fromNetworkFormat( - txBytes, - "zec", - rootWalletKeys, - unspents, - { blockHeight: ZCASH_NU5_HEIGHT }, - ); - - assert.ok(reconstructed.serialize().length > 0, "PSBT serializes"); - assert.ok( - reconstructed.verifySignature(0, userXprv.neutered().toBase58()), - "User signature present in input 0", - ); - assert.ok( - reconstructed.verifySignature(0, bitgoXprv.neutered().toBase58()), - "Bitgo signature present in input 0", - ); - }); - - it("zec: accepts pre-decoded ZcashTransaction instance", function () { - const rootWalletKeys = getDefaultWalletKeys(); - const { psbt, unspents } = createHalfSignedP2msPsbt("zec"); - const txBytes = psbt.getHalfSignedLegacyFormat(); - const tx = ZcashTransaction.fromBytes(txBytes); - - const reconstructed = ZcashBitGoPsbt.fromNetworkFormat(tx, "zec", rootWalletKeys, unspents, { - blockHeight: ZCASH_NU5_HEIGHT, - }); - - assert.ok(reconstructed instanceof ZcashBitGoPsbt, "Returns ZcashBitGoPsbt"); - assert.ok(reconstructed.serialize().length > 0, "Serializes without error"); - }); - }); -}); diff --git a/packages/wasm-utxo/test/fixedScript/fromNetworkFormat.ts b/packages/wasm-utxo/test/fixedScript/fromNetworkFormat.ts new file mode 100644 index 00000000000..45527dc9c78 --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/fromNetworkFormat.ts @@ -0,0 +1,242 @@ +import { describe, it } from "mocha"; +import * as assert from "assert"; +import { BitGoPsbt, type HydrationUnspent } from "../../js/fixedScriptWallet/BitGoPsbt.js"; +import { ZcashBitGoPsbt } from "../../js/fixedScriptWallet/ZcashBitGoPsbt.js"; +import { supportsScriptType } from "../../js/fixedScriptWallet/index.js"; +import { ChainCode } from "../../js/fixedScriptWallet/chains.js"; +import { ECPair } from "../../js/ecpair.js"; +import { ZcashTransaction } from "../../js/transaction.js"; +import { coinNames, type CoinName, isMainnet } from "../../js/coinName.js"; +import { getDefaultWalletKeys, getKeyTriple } from "../../js/testutils/keys.js"; + +const ZCASH_NU5_HEIGHT = 1687105; + +const p2msScriptTypes = ["p2sh", "p2shP2wsh", "p2wsh"] as const; + +const EXCLUDED_COINS: CoinName[] = ["bsv", "bcha"]; + +function isSupportedCoin(coin: CoinName): boolean { + return isMainnet(coin) && !EXCLUDED_COINS.includes(coin); +} + +function fromNetworkFormat( + txBytes: Uint8Array, + coinName: CoinName, + rootWalletKeys: ReturnType, + unspents: HydrationUnspent[], +): BitGoPsbt { + if (coinName === "zec" || coinName === "tzec") { + return ZcashBitGoPsbt.fromNetworkFormat(txBytes, coinName, rootWalletKeys, unspents, { + blockHeight: ZCASH_NU5_HEIGHT, + }); + } + return BitGoPsbt.fromNetworkFormat(txBytes, coinName, rootWalletKeys, unspents); +} + +function createSignedP2msPsbt( + coinName: CoinName, + sigCount: 1 | 2, + valueOverride?: bigint, +): { txBytes: Uint8Array; unspents: HydrationUnspent[] } { + const rootWalletKeys = getDefaultWalletKeys(); + const [userXprv, , bitgoXprv] = getKeyTriple("default"); + + const supportedTypes = p2msScriptTypes.filter((scriptType) => + supportsScriptType(coinName, scriptType), + ); + + const isZcash = coinName === "zec" || coinName === "tzec"; + const psbt = isZcash + ? ZcashBitGoPsbt.createEmpty(coinName, rootWalletKeys, { + version: 4, + lockTime: 0, + blockHeight: ZCASH_NU5_HEIGHT, + }) + : BitGoPsbt.createEmpty(coinName, rootWalletKeys, { version: 2, lockTime: 0 }); + + const unspents: HydrationUnspent[] = []; + supportedTypes.forEach((scriptType, index) => { + const chain = ChainCode.value(scriptType, "external"); + const value = valueOverride ?? BigInt(10000 + index * 10000); + psbt.addWalletInput( + { + txid: `${"00".repeat(31)}${index.toString(16).padStart(2, "0")}`, + vout: 0, + value, + sequence: 0xfffffffd, + }, + rootWalletKeys, + { scriptId: { chain, index } }, + ); + unspents.push({ chain, index, value }); + }); + + psbt.addWalletOutput(rootWalletKeys, { chain: 0, index: 100, value: BigInt(5000) }); + psbt.sign(userXprv); + + if (sigCount === 2) { + psbt.sign(bitgoXprv); + psbt.finalizeAllInputs(); + return { txBytes: psbt.extractTransaction().toBytes(), unspents }; + } + + return { txBytes: psbt.getHalfSignedLegacyFormat(), unspents }; +} + +describe("BitGoPsbt.fromNetworkFormat", function () { + const [userXprv, , bitgoXprv] = getKeyTriple("default"); + + describe("BigInt value conversion (regression for unchecked-from/as_f64 bug)", function () { + it("does not throw for JS BigInt unspent values", function () { + // BigInt::from(value_js).as_f64() returned None for JS BigInt (only works for JS Number), + // causing "'value' must be a bigint". Fixed by using u64::try_from directly. + const rootWalletKeys = getDefaultWalletKeys(); + const { txBytes, unspents } = createSignedP2msPsbt("btc", 1); + assert.doesNotThrow(() => + BitGoPsbt.fromNetworkFormat(txBytes, "btc", rootWalletKeys, unspents), + ); + }); + + it("preserves values larger than Number.MAX_SAFE_INTEGER", function () { + const rootWalletKeys = getDefaultWalletKeys(); + const maxSats = 21_000_000n * 100_000_000n; + const { txBytes, unspents } = createSignedP2msPsbt("btc", 1, maxSats); + assert.doesNotThrow(() => + BitGoPsbt.fromNetworkFormat(txBytes, "btc", rootWalletKeys, unspents), + ); + }); + }); + + describe("half-signed input", function () { + for (const coinName of coinNames.filter(isSupportedCoin)) { + it(`${coinName}: user signature preserved`, function () { + const rootWalletKeys = getDefaultWalletKeys(); + const { txBytes, unspents } = createSignedP2msPsbt(coinName, 1); + const reconstructed = fromNetworkFormat(txBytes, coinName, rootWalletKeys, unspents); + assert.ok(reconstructed.serialize().length > 0); + assert.ok(reconstructed.verifySignature(0, userXprv.neutered().toBase58())); + }); + } + }); + + describe("full-signed input", function () { + for (const coinName of coinNames.filter(isSupportedCoin)) { + it(`${coinName}: both user and bitgo signatures preserved`, function () { + const rootWalletKeys = getDefaultWalletKeys(); + const { txBytes, unspents } = createSignedP2msPsbt(coinName, 2); + const reconstructed = fromNetworkFormat(txBytes, coinName, rootWalletKeys, unspents); + assert.ok(reconstructed.serialize().length > 0); + assert.ok(reconstructed.verifySignature(0, userXprv.neutered().toBase58())); + assert.ok(reconstructed.verifySignature(0, bitgoXprv.neutered().toBase58())); + }); + } + }); + + describe("with replay protection input", function () { + type RpCase = { coin: CoinName; desc: string }; + const rpCoins: RpCase[] = [ + { coin: "btc", desc: "BTC" }, + { coin: "bch", desc: "BCH (SIGHASH_FORKID)" }, + ]; + + for (const { coin, desc } of rpCoins) { + it(`${desc}: half-signed — wallet and p2shP2pk sigs preserved`, function () { + const rootWalletKeys = getDefaultWalletKeys(); + const ecpair = ECPair.fromPrivateKey(Buffer.from(userXprv.privateKey)); + + const psbt = BitGoPsbt.createEmpty(coin, 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) }); + psbt.sign(userXprv); + psbt.signInput(1, ecpair); + + const txBytes = psbt.getHalfSignedLegacyFormat(); + const unspents: HydrationUnspent[] = [ + { chain: 0, index: 0, value: BigInt(10000) }, + { pubkey: ecpair.publicKey, value: BigInt(1000) }, + ]; + const reconstructed = BitGoPsbt.fromNetworkFormat(txBytes, coin, rootWalletKeys, unspents); + + assert.strictEqual(reconstructed.inputCount(), 2); + assert.ok(reconstructed.verifySignature(0, userXprv.neutered().toBase58()), "wallet sig"); + assert.ok(reconstructed.verifySignature(1, ecpair), "p2shP2pk sig"); + }); + + it(`${desc}: full-signed — both wallet sigs and p2shP2pk sig preserved`, function () { + const rootWalletKeys = getDefaultWalletKeys(); + const ecpair = ECPair.fromPrivateKey(Buffer.from(userXprv.privateKey)); + + const psbt = BitGoPsbt.createEmpty(coin, 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) }); + psbt.sign(userXprv); + psbt.sign(bitgoXprv); + psbt.signInput(1, ecpair); + psbt.finalizeAllInputs(); + const txBytes = psbt.extractTransaction().toBytes(); + + const unspents: HydrationUnspent[] = [ + { chain: 0, index: 0, value: BigInt(10000) }, + { pubkey: ecpair.publicKey, value: BigInt(1000) }, + ]; + const reconstructed = BitGoPsbt.fromNetworkFormat(txBytes, coin, rootWalletKeys, unspents); + + assert.strictEqual(reconstructed.inputCount(), 2); + assert.ok( + reconstructed.verifySignature(0, userXprv.neutered().toBase58()), + "user wallet sig", + ); + assert.ok( + reconstructed.verifySignature(0, bitgoXprv.neutered().toBase58()), + "bitgo wallet sig", + ); + assert.ok(reconstructed.verifySignature(1, ecpair), "p2shP2pk sig"); + }); + } + }); + + describe("ZcashBitGoPsbt Zcash-specific options", function () { + it("accepts consensusBranchId instead of blockHeight", function () { + const rootWalletKeys = getDefaultWalletKeys(); + const { txBytes, unspents } = createSignedP2msPsbt("zec", 1); + const reconstructed = ZcashBitGoPsbt.fromNetworkFormat( + txBytes, + "zec", + rootWalletKeys, + unspents, + { + consensusBranchId: 0xc2d6d0b4, + }, + ); + assert.ok(reconstructed instanceof ZcashBitGoPsbt); + assert.ok(reconstructed.verifySignature(0, userXprv.neutered().toBase58())); + }); + + it("accepts pre-decoded ZcashTransaction instance", function () { + const rootWalletKeys = getDefaultWalletKeys(); + const { txBytes, unspents } = createSignedP2msPsbt("zec", 1); + const tx = ZcashTransaction.fromBytes(txBytes); + const reconstructed = ZcashBitGoPsbt.fromNetworkFormat(tx, "zec", rootWalletKeys, unspents, { + blockHeight: ZCASH_NU5_HEIGHT, + }); + assert.ok(reconstructed instanceof ZcashBitGoPsbt); + assert.ok(reconstructed.serialize().length > 0); + }); + }); +});