diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index 13debcd2289..03d025c5858 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -931,6 +931,22 @@ export class BitGoPsbt extends PsbtBase implements IPsbtWithAddre this._wasm.combine_musig2_nonces(sourcePsbt.wasm); } + /** + * Merge all input fields from a raw PSBT (bytes) into this PSBT. + * + * The source bytes are parsed with the underlying bitcoin PSBT deserializer, + * bypassing network-specific validation (e.g. ZCash consensusBranchId), so the + * source may be a stripped PSBT that lacks those fields. + * + * Copies per input: partial_sigs, tap_key_sig, tap_script_sigs, proprietary. + * + * @param otherPsbtBytes - Raw bytes of the PSBT to merge signatures from + * @throws Error if PSBT parsing fails or input counts don't match + */ + combineInputs(otherPsbtBytes: Uint8Array): void { + this._wasm.combine_inputs(otherPsbtBytes); + } + /** * Finalize all inputs in the PSBT * 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 1916017f727..d5c22c3fe24 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 @@ -1300,6 +1300,52 @@ impl BitGoPsbt { Ok(()) } + /// Merge all input fields from a raw PSBT (given as bytes) into this PSBT. + /// + /// For Zcash PSBTs the source is parsed with the ZEC-aware deserializer (skipping + /// the ZecConsensusBranchId check so that stripped HSM responses are accepted). + /// For all other coins the bitcoin PSBT deserializer is used. + /// + /// Copies per input: partial_sigs, tap_key_sig, tap_script_sigs, proprietary. + pub fn combine_inputs(&mut self, other_bytes: &[u8]) -> Result<(), String> { + let raw: Psbt = match self { + BitGoPsbt::Zcash(_, network) => { + ZcashBitGoPsbt::deserialize_stripped(other_bytes, *network) + .map(|z| z.psbt) + .map_err(|e| format!("Failed to parse PSBT: {}", e))? + } + _ => Psbt::deserialize(other_bytes) + .map_err(|e| format!("Failed to parse PSBT: {}", e))?, + }; + + let dest = self.psbt_mut(); + + if raw.inputs.len() != dest.inputs.len() { + return Err(format!( + "PSBT input count mismatch: source has {}, destination has {}", + raw.inputs.len(), + dest.inputs.len() + )); + } + + for (src_in, dest_in) in raw.inputs.iter().zip(dest.inputs.iter_mut()) { + for (k, v) in &src_in.partial_sigs { + dest_in.partial_sigs.insert(*k, *v); + } + if let Some(sig) = src_in.tap_key_sig { + dest_in.tap_key_sig = Some(sig); + } + for (k, v) in &src_in.tap_script_sigs { + dest_in.tap_script_sigs.insert(*k, *v); + } + for (k, v) in &src_in.proprietary { + dest_in.proprietary.insert(k.clone(), v.clone()); + } + } + + Ok(()) + } + /// Serialize the PSBT to bytes, using network-specific logic pub fn serialize(&self) -> Result, SerializeError> { match self { diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs index 96717afbf2f..5158b607d7c 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs @@ -204,10 +204,21 @@ impl ZcashBitGoPsbt { Ok(hash.to_byte_array()) } + /// Deserialize a Zcash PSBT from bytes without requiring the ZecConsensusBranchId + /// proprietary key. Used when combining with a stripped HSM response that may not + /// carry the branch ID (the key is only needed for sighash, not for merging sigs). + pub(crate) fn deserialize_stripped( + bytes: &[u8], + network: crate::Network, + ) -> Result { + Self::decode_with_zcash_tx(bytes, network, false) + } + /// Deserialize the PSBT by converting the Zcash transaction to Bitcoin format first fn decode_with_zcash_tx( bytes: &[u8], network: crate::Network, + require_branch_id: bool, ) -> Result { let mut r = bytes; @@ -342,7 +353,7 @@ impl ZcashBitGoPsbt { let psbt = Psbt::deserialize(&modified_psbt)?; // Consensus branch ID must be set in the PSBT proprietary map - if super::propkv::get_zec_consensus_branch_id(&psbt).is_none() { + if require_branch_id && super::propkv::get_zec_consensus_branch_id(&psbt).is_none() { return Err(super::DeserializeError::Network( "Missing ZecConsensusBranchId in PSBT proprietary map".to_string(), )); @@ -366,7 +377,7 @@ impl ZcashBitGoPsbt { bytes: &[u8], network: crate::Network, ) -> Result { - Self::decode_with_zcash_tx(bytes, network) + Self::decode_with_zcash_tx(bytes, network, true) } /// Convert to a standard Bitcoin PSBT (losing Zcash-specific fields) diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs index 0a8675beeb8..ef5c987c23d 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -971,7 +971,6 @@ impl BitGoPsbt { _ => None, } } - pub fn get_outputs_with_address(&self) -> Result { crate::wasm::psbt::get_outputs_with_address_from_psbt(self.psbt.psbt(), self.psbt.network()) } @@ -1816,6 +1815,20 @@ impl BitGoPsbt { .map_err(|e| WasmUtxoError::new(&format!("Failed to combine PSBTs: {}", e))) } + /// Merge all input fields from a raw PSBT (given as bytes) into this PSBT. + /// + /// The source bytes are parsed with the underlying bitcoin PSBT deserializer, + /// bypassing any network-specific validation, so the source may be a partial + /// PSBT that lacks fields like ZecConsensusBranchId. + /// + /// # Errors + /// Returns error if PSBT parsing fails or input counts don't match + pub fn combine_inputs(&mut self, other_bytes: &[u8]) -> Result<(), WasmUtxoError> { + self.psbt + .combine_inputs(other_bytes) + .map_err(|e| WasmUtxoError::new(&format!("Failed to combine inputs: {}", e))) + } + /// Finalize all inputs in the PSBT /// /// This method attempts to finalize all inputs in the PSBT, computing the final diff --git a/packages/wasm-utxo/test/fixedScript/combineInputs.ts b/packages/wasm-utxo/test/fixedScript/combineInputs.ts new file mode 100644 index 00000000000..3098d8590cd --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/combineInputs.ts @@ -0,0 +1,294 @@ +import assert from "node:assert"; +import { describe, it } from "mocha"; + +import { fixedScriptWallet } from "../../js/index.js"; +import { ZcashBitGoPsbt } from "../../js/fixedScriptWallet/ZcashBitGoPsbt.js"; +import { + AcidTest, + getKeyTriple, + getDefaultWalletKeys, + getWalletKeysForSeed, +} from "../../js/testutils/index.js"; +import type { Input, Output } from "../../js/testutils/AcidTest.js"; +import type { CoinName } from "../../js/coinName.js"; + +const SAPLING_ACTIVATION_HEIGHT = 419200; +const SAPLING_BRANCH_ID = 0x76b809bb; +const NU5_BRANCH_ID = 0xc2d6d0b4; + +function makeAcidTest(coin: CoinName, inputs: Input[], outputs: Output[]): AcidTest { + return new AcidTest( + coin, + "unsigned", + "psbt", + getDefaultWalletKeys(), + getWalletKeysForSeed("too many secrets"), + inputs, + outputs, + getKeyTriple("default"), + ); +} + +function signCopy( + unsignedBytes: Uint8Array, + coin: CoinName, + keyIndex: 0 | 1 | 2, +): fixedScriptWallet.BitGoPsbt { + const [user, backup, bitgo] = getKeyTriple("default"); + const keys = [user, backup, bitgo]; + const psbt = fixedScriptWallet.BitGoPsbt.fromBytes(unsignedBytes, coin); + psbt.sign(keys[keyIndex]); + return psbt; +} + +function makeZecAcidTest(inputs: Input[] = [{ scriptType: "p2sh", value: 100000n }]): AcidTest { + const outputs: Output[] = [{ scriptType: "p2sh", value: 90000n, walletKeys: null }]; + return makeAcidTest("zec", inputs, outputs); +} + +describe("BitGoPsbt.combineInputs", function () { + describe("BTC (p2shP2wsh)", function () { + const acidTest = makeAcidTest( + "btc", + [{ scriptType: "p2shP2wsh", value: 100000n }], + [{ scriptType: "p2sh", value: 90000n, walletKeys: null }], + ); + const unsignedBytes = acidTest.createPsbt().serialize(); + + it("merges bitgo signatures into user-signed PSBT", function () { + const request = signCopy(unsignedBytes, "btc", 0); + const response = signCopy(unsignedBytes, "btc", 2); + + request.combineInputs(response.serialize()); + + const rootWalletKeys = acidTest.rootWalletKeys; + assert.ok(request.verifySignature(0, rootWalletKeys.userKey()), "user sig should be present"); + assert.ok( + request.verifySignature(0, rootWalletKeys.bitgoKey()), + "bitgo sig should be present", + ); + }); + + it("finalizes and extracts after combining user + bitgo", function () { + const request = signCopy(unsignedBytes, "btc", 0); + const response = signCopy(unsignedBytes, "btc", 2); + + request.combineInputs(response.serialize()); + request.finalizeAllInputs(); + const tx = request.extractTransaction(); + + assert.ok(tx.getId(), "should have txid"); + assert.match(tx.getId(), /^[0-9a-f]{64}$/, "txid should be 64 hex chars"); + assert.ok(tx.toBytes().length > 0, "tx bytes should be non-empty"); + }); + + it("does not add signatures when response is unsigned", function () { + const request = signCopy(unsignedBytes, "btc", 0); + const emptyResponse = fixedScriptWallet.BitGoPsbt.fromBytes(unsignedBytes, "btc"); + + request.combineInputs(emptyResponse.serialize()); + + const rootWalletKeys = acidTest.rootWalletKeys; + assert.ok(request.verifySignature(0, rootWalletKeys.userKey()), "user sig should remain"); + assert.ok( + !request.verifySignature(0, rootWalletKeys.bitgoKey()), + "bitgo sig should be absent", + ); + }); + + it("is idempotent: combining the same response twice yields the same result", function () { + const request1 = signCopy(unsignedBytes, "btc", 0); + const request2 = fixedScriptWallet.BitGoPsbt.fromBytes(request1.serialize(), "btc"); + const response = signCopy(unsignedBytes, "btc", 2); + const responseBytes = response.serialize(); + + request1.combineInputs(responseBytes); + request2.combineInputs(responseBytes); + request2.combineInputs(responseBytes); + + assert.deepStrictEqual( + Buffer.from(request1.serialize()).toString("hex"), + Buffer.from(request2.serialize()).toString("hex"), + "combining twice should yield the same PSBT", + ); + }); + + it("throws on malformed response bytes", function () { + const request = signCopy(unsignedBytes, "btc", 0); + + assert.throws( + () => request.combineInputs(Buffer.from("aabbccdd", "hex")), + /Failed to (parse|combine)/, + ); + }); + + it("throws when input counts differ", function () { + const twoInputTest = makeAcidTest( + "btc", + [ + { scriptType: "p2shP2wsh", value: 100000n }, + { scriptType: "p2shP2wsh", value: 200000n }, + ], + [{ scriptType: "p2sh", value: 90000n, walletKeys: null }], + ); + const twoInputBytes = twoInputTest.createPsbt().serialize(); + + const request = signCopy(unsignedBytes, "btc", 0); + const response = fixedScriptWallet.BitGoPsbt.fromBytes(twoInputBytes, "btc"); + + assert.throws(() => request.combineInputs(response.serialize()), /input count mismatch/i); + }); + }); + + describe("LTC (p2shP2wsh)", function () { + const acidTest = makeAcidTest( + "ltc", + [{ scriptType: "p2shP2wsh", value: 100000n }], + [{ scriptType: "p2sh", value: 90000n, walletKeys: null }], + ); + const unsignedBytes = acidTest.createPsbt().serialize(); + + it("combines and finalizes for LTC", function () { + const request = signCopy(unsignedBytes, "ltc", 0); + const response = signCopy(unsignedBytes, "ltc", 2); + + request.combineInputs(response.serialize()); + request.finalizeAllInputs(); + const tx = request.extractTransaction(); + + assert.ok(tx.getId(), "should have txid"); + }); + }); + + describe("ZEC (p2sh)", function () { + it("merges bitgo signatures from a ZEC response PSBT", function () { + const acidTest = makeZecAcidTest(); + const unsignedBytes = acidTest.createPsbt().serialize(); + const [userKey, , bitgoKey] = getKeyTriple("default"); + + const requestPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(unsignedBytes, "zec"); + requestPsbt.sign(userKey); + + const responsePsbt = fixedScriptWallet.BitGoPsbt.fromBytes(unsignedBytes, "zec"); + responsePsbt.sign(bitgoKey); + + requestPsbt.combineInputs(responsePsbt.serialize()); + + const rootWalletKeys = acidTest.rootWalletKeys; + assert.ok( + requestPsbt.verifySignature(0, rootWalletKeys.userKey()), + "user sig should be present", + ); + assert.ok( + requestPsbt.verifySignature(0, rootWalletKeys.bitgoKey()), + "bitgo sig should be present", + ); + }); + + it("finalizes and extracts after combining user + bitgo on ZEC", function () { + const acidTest = makeZecAcidTest(); + const unsignedBytes = acidTest.createPsbt().serialize(); + const [userKey, , bitgoKey] = getKeyTriple("default"); + + const requestPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(unsignedBytes, "zec"); + requestPsbt.sign(userKey); + + const responsePsbt = fixedScriptWallet.BitGoPsbt.fromBytes(unsignedBytes, "zec"); + responsePsbt.sign(bitgoKey); + + requestPsbt.combineInputs(responsePsbt.serialize()); + requestPsbt.finalizeAllInputs(); + const tx = requestPsbt.extractTransaction(); + + assert.ok(tx.getId(), "should produce a txid"); + assert.match(tx.getId(), /^[0-9a-f]{64}$/, "txid should be 64 hex chars"); + }); + + it("works when response has no ZecConsensusBranchId (stripped HSM response)", function () { + const acidTest = makeZecAcidTest(); + const unsignedBytes = acidTest.createPsbt().serialize(); + const [userKey, , bitgoKey] = getKeyTriple("default"); + + const requestPsbt = fixedScriptWallet.BitGoPsbt.fromBytes(unsignedBytes, "zec"); + requestPsbt.sign(userKey); + + const responsePsbt = fixedScriptWallet.BitGoPsbt.fromBytes(unsignedBytes, "zec"); + responsePsbt.sign(bitgoKey); + + assert.doesNotThrow(() => requestPsbt.combineInputs(responsePsbt.serialize())); + }); + + it("signs user + bitgo on the same PSBT, finalizes, and extracts", function () { + const acidTest = makeZecAcidTest(); + const [userKey, , bitgoKey] = getKeyTriple("default"); + + const psbt = acidTest.createPsbt(); + psbt.sign(userKey); + psbt.sign(bitgoKey); + psbt.finalizeAllInputs(); + const tx = psbt.extractTransaction(); + + assert.ok(tx.getId(), "should produce a txid"); + assert.match(tx.getId(), /^[0-9a-f]{64}$/, "txid should be 64 hex chars"); + assert.ok(tx.toBytes().length > 0, "tx bytes should be non-empty"); + }); + + it("verifySignature reflects correct state after user + bitgo sign", function () { + const acidTest = makeZecAcidTest(); + const [userKey, , bitgoKey] = getKeyTriple("default"); + const rootWalletKeys = acidTest.rootWalletKeys; + + const psbt = acidTest.createPsbt(); + assert.ok(!psbt.verifySignature(0, rootWalletKeys.userKey()), "no user sig before signing"); + + psbt.sign(userKey); + assert.ok(psbt.verifySignature(0, rootWalletKeys.userKey()), "user sig after sign"); + assert.ok(!psbt.verifySignature(0, rootWalletKeys.bitgoKey()), "no bitgo sig yet"); + + psbt.sign(bitgoKey); + assert.ok(psbt.verifySignature(0, rootWalletKeys.bitgoKey()), "bitgo sig after sign"); + }); + }); + + describe("ZcashBitGoPsbt metadata", function () { + it("consensusBranchId getter returns the branch ID stored in the PSBT", function () { + const rootWalletKeys = getDefaultWalletKeys(); + const psbt = ZcashBitGoPsbt.createEmptyWithConsensusBranchId("zec", rootWalletKeys, { + consensusBranchId: SAPLING_BRANCH_ID, + }); + + assert.strictEqual(psbt.consensusBranchId, SAPLING_BRANCH_ID); + }); + + it("branchIdForHeight returns Sapling branch ID at Sapling activation height", function () { + const branchId = ZcashBitGoPsbt.branchIdForHeight("zec", SAPLING_ACTIVATION_HEIGHT); + assert.strictEqual(branchId, SAPLING_BRANCH_ID); + }); + + it("branchIdForHeight returns NU5 branch ID at NU5 activation height (1687104)", function () { + const NU5_ACTIVATION_HEIGHT = 1687104; + const branchId = ZcashBitGoPsbt.branchIdForHeight("zec", NU5_ACTIVATION_HEIGHT); + assert.strictEqual(branchId, NU5_BRANCH_ID); + }); + + it("branchIdForHeight returns undefined before Overwinter activation", function () { + const branchId = ZcashBitGoPsbt.branchIdForHeight("zec", 1); + assert.strictEqual(branchId, undefined); + }); + + it("ZcashBitGoPsbt.fromBytes throws when ZecConsensusBranchId is absent", function () { + const btcBytes = makeAcidTest( + "btc", + [{ scriptType: "p2sh", value: 100000n }], + [{ scriptType: "p2sh", value: 90000n, walletKeys: null }], + ) + .createPsbt() + .serialize(); + + assert.throws( + () => ZcashBitGoPsbt.fromBytes(btcBytes, "zec"), + /ZecConsensusBranchId|consensus_branch_id|failed|invalid/i, + ); + }); + }); +});