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 deleted file mode 100644 index 18c56aae0d4..00000000000 --- a/packages/wasm-utxo/test/fixedScript/fromHalfSignedLegacyTransaction.ts +++ /dev/null @@ -1,445 +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 () { - 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); - - 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 txBytes = psbt.getHalfSignedLegacyFormat(); - const tx = Transaction.fromBytes(txBytes, "btc"); - - 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, - ); - - 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", - ); - }); - }); - - 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("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); + }); + }); +});