diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts index 63b477b9bfa..3f622569706 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts @@ -28,6 +28,9 @@ export type ZcashParsedOutput = ParsedOutput & { isShielded: boolean; }; +/** Zcash transaction versions supported by the fixed-script PSBT API. */ +export type ZcashTransactionVersion = 4 | 6; + /** * Zcash v6 (Ironwood) version group id (0xd884b698). Its presence marks a PSBT as v6 — see * `ZcashIronwoodBitGoPsbt`. @@ -182,6 +185,11 @@ export class ZcashBitGoPsbt extends BitGoPsbt { return psbt; } + /** @internal Create from a parsed WASM instance without reparsing the bytes. */ + static fromWasm(wasm: WasmBitGoPsbt): ZcashBitGoPsbt { + return new ZcashBitGoPsbt(wasm); + } + /** * Reconstruct a Zcash PSBT from a network-format transaction (unsigned, half-signed, or fully-signed). * @@ -294,6 +302,16 @@ export class ZcashBitGoPsbt extends BitGoPsbt { return this.wasm.version_group_id(); } + /** + * Get the Zcash transaction version represented by this PSBT. + * + * The version group ID is the discriminator because the v4 and v6 PSBT encodings do not expose + * the same unsigned transaction bytes to a standard PSBT parser. + */ + getVersion(): ZcashTransactionVersion { + return this.versionGroupId === IRONWOOD_VERSION_GROUP_ID ? 6 : 4; + } + /** * Get the Zcash expiry height * @returns The expiry height (0 if not set) diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts index 5092649841d..b01e137db85 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts @@ -187,6 +187,11 @@ export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt { return psbt; } + /** @internal Create from a parsed WASM instance without reparsing the bytes. */ + static override fromWasm(wasm: WasmBitGoPsbt): ZcashIronwoodBitGoPsbt { + return new ZcashIronwoodBitGoPsbt(wasm); + } + /** * Add the shielded output (Constructor role). Stores the orchard PCZT in the PSBT. * diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashPsbt.ts new file mode 100644 index 00000000000..55ffabbc5ba --- /dev/null +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashPsbt.ts @@ -0,0 +1,25 @@ +import { BitGoPsbt as WasmBitGoPsbt } from "../wasm/wasm_utxo.js"; +import { ZcashBitGoPsbt, type ZcashNetworkName } from "./ZcashBitGoPsbt.js"; +import { ZcashIronwoodBitGoPsbt } from "./ZcashIronwoodBitGoPsbt.js"; + +export type ZcashPsbtInstance = ZcashBitGoPsbt | ZcashIronwoodBitGoPsbt; + +/** Factory for deserializing either supported Zcash PSBT format. */ +export class ZcashPsbt { + private constructor() {} + + /** + * Deserialize a Zcash PSBT and return the format-specific implementation. + * + * The version is read from the parsed Zcash metadata, so callers do not need to run a separate + * byte-level detector or choose a concrete parser before deserializing. + */ + static from(bytes: Uint8Array, network: ZcashNetworkName): ZcashPsbtInstance { + const parsed = ZcashBitGoPsbt.fromWasm(WasmBitGoPsbt.from_bytes(bytes, network)); + return parsed.getVersion() === 6 ? ZcashIronwoodBitGoPsbt.fromWasm(parsed.wasm) : parsed; + } + + static fromBytes(bytes: Uint8Array, network: ZcashNetworkName): ZcashPsbtInstance { + return ZcashPsbt.from(bytes, network); + } +} diff --git a/packages/wasm-utxo/js/fixedScriptWallet/index.ts b/packages/wasm-utxo/js/fixedScriptWallet/index.ts index 0b210393af7..3df97923ffb 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/index.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/index.ts @@ -44,10 +44,11 @@ export { ZcashBitGoPsbt, type ZcashNetworkName, type ZcashParsedOutput, + type ZcashTransactionVersion, type CreateEmptyZcashOptions, IRONWOOD_VERSION_GROUP_ID, } from "./ZcashBitGoPsbt.js"; - +export { ZcashPsbt, type ZcashPsbtInstance } from "./ZcashPsbt.js"; // Zcash v6 (Ironwood / NU6.3) shielding PSBT export { ZcashIronwoodBitGoPsbt, diff --git a/packages/wasm-utxo/js/index.ts b/packages/wasm-utxo/js/index.ts index 893cbaf6bc5..e7dcfdab916 100644 --- a/packages/wasm-utxo/js/index.ts +++ b/packages/wasm-utxo/js/index.ts @@ -23,6 +23,16 @@ export { ECPair } from "./ecpair.js"; export { BIP32 } from "./bip32.js"; export { Dimensions } from "./fixedScriptWallet/Dimensions.js"; export { ZcashDimensions } from "./fixedScriptWallet/ZcashDimensions.js"; +export { + ZcashBitGoPsbt, + ZcashPsbt, + ZcashIronwoodBitGoPsbt, + type ZcashPsbtInstance, + type ZcashTransactionVersion, + ZcashUnifiedAddress, + ZcashV6Transaction, + ZcashIronwoodWitness, +} from "./fixedScriptWallet/index.js"; export type WasmUtxoVersionInfo = { version: string; gitHash: string }; export function getWasmUtxoVersion(): WasmUtxoVersionInfo { 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 9098c016e87..c18d7c2c4d3 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 @@ -271,13 +271,13 @@ impl ZcashBitGoPsbt { // `psbt\xff` magic, so a failed parse here is not conclusive — fall through to the v4 path // and let it report the real error. // - // Delegate to `deserialize_v6` rather than constructing the struct inline: it validates the - // declared version group id, the consensus branch id and the PCZT. Trusting them would let a - // PSBT that carries ZecV6Params but a non-Ironwood version group id through with - // `is_ironwood_v6() == false`, which then routes `serialize()` back down the v4 path. + // Delegate to the pre-shield v6 decoder rather than constructing the struct inline. A PSBT + // can be serialized between building its transparent skeleton and adding the PCZT, so the + // generic entry point must accept both states. The decoder still validates the declared + // version group id and consensus branch id. if let Ok(psbt) = Psbt::deserialize(bytes) { if super::propkv::get_zec_v6_params(&psbt).is_some() { - return Self::deserialize_v6(bytes, network); + return Self::deserialize_v6_pre_shield(bytes, network); } } @@ -344,17 +344,19 @@ impl ZcashBitGoPsbt { expiry_height = parts.expiry_height; sapling_fields = parts.sapling_fields; - // Serialize the modified transaction - let mut tx_bytes = Vec::new(); - parts - .transaction - .consensus_encode(&mut tx_bytes) - .map_err(|e| { - super::DeserializeError::Network(format!( - "Failed to encode transaction: {}", - e - )) - })?; + // Serialize the modified transaction without the segwit marker/flag. The + // transaction is embedded in a PSBT global map and must use the legacy unsigned + // transaction encoding, including for an empty transaction. + let tx_bytes = crate::zcash::transaction::encode_zcash_transaction_parts( + &crate::zcash::transaction::ZcashTransactionParts { + transaction: parts.transaction, + is_overwintered: false, + version_group_id: None, + expiry_height: None, + sapling_fields: Vec::new(), + }, + ) + .map_err(super::DeserializeError::Network)?; // Write key VarInt(key_data.len() as u64) @@ -1585,6 +1587,11 @@ impl ZcashBitGoPsbt { "v6 PSBT is missing its Ironwood PCZT".to_string(), )); } + if !require_pczt && super::propkv::is_ironwood_extracted(&psbt) { + return Err(super::DeserializeError::Network( + "v6 PSBT is missing its Ironwood PCZT after extraction".to_string(), + )); + } Ok(ZcashBitGoPsbt { psbt, network, @@ -1636,6 +1643,27 @@ mod tests { assert!(parts.sapling_fields.is_empty()); } + #[test] + fn test_round_trip_empty_zcash_psbt() { + use crate::fixed_script_wallet::test_utils::get_test_wallet_keys; + use crate::fixed_script_wallet::RootWalletKeys; + use crate::networks::Network; + + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys("empty-zcash-round-trip")); + let psbt = ZcashBitGoPsbt::new( + Network::Zcash, + &wallet_keys, + 0xc2d6d0b4, + Some(4), + Some(0), + Some(ZCASH_SAPLING_VERSION_GROUP_ID), + Some(0), + ); + let bytes = psbt.serialize().unwrap(); + + ZcashBitGoPsbt::deserialize(&bytes, Network::Zcash).unwrap(); + } + #[test] fn test_round_trip_zcash_psbt() { use crate::fixed_script_wallet::test_utils::fixtures::{ diff --git a/packages/wasm-utxo/src/zcash/transaction.rs b/packages/wasm-utxo/src/zcash/transaction.rs index aaac87c90bf..4e827a76610 100644 --- a/packages/wasm-utxo/src/zcash/transaction.rs +++ b/packages/wasm-utxo/src/zcash/transaction.rs @@ -13,9 +13,6 @@ pub const ZCASH_SAPLING_VERSION_GROUP_ID: u32 = 0x892F2085; /// Zcash Ironwood version group ID (v6 / NU6.3 transactions) pub const ZCASH_IRONWOOD_VERSION_GROUP_ID: u32 = 0xD884B698; -/// Transaction version header for v4 transactions (Sapling), overwintered bit set. -pub const ZCASH_V4_VERSION_HEADER: u32 = 0x80000004; - /// Transaction version header for v6 transactions (Ironwood/NU6.3), overwintered bit set. pub const ZCASH_V6_VERSION_HEADER: u32 = 0x80000006; diff --git a/packages/wasm-utxo/test/fixedScript/zcashPsbt.ts b/packages/wasm-utxo/test/fixedScript/zcashPsbt.ts new file mode 100644 index 00000000000..28a654491dc --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/zcashPsbt.ts @@ -0,0 +1,54 @@ +import assert from "node:assert"; +import { describe, it } from "mocha"; + +import { ZcashBitGoPsbt, ZcashIronwoodBitGoPsbt, ZcashPsbt } from "../../js/index.js"; +import { BitGoPsbt } from "../../js/fixedScriptWallet/index.js"; +import { getWalletKeysForSeed } from "../../js/testutils/index.js"; + +const LEGACY_V4_MAINNET_HEIGHT = 1687104; +const NU6_3_TESTNET_HEIGHT = 4134000; + +describe("ZcashPsbt", function () { + const walletKeys = getWalletKeysForSeed("zcash-psbt-factory-test"); + + it("returns the v4 implementation for a legacy PSBT", function () { + const original = ZcashBitGoPsbt.createEmpty("zcash", walletKeys, { + blockHeight: LEGACY_V4_MAINNET_HEIGHT, + }); + + const psbt = ZcashPsbt.from(original.serialize(), "zcash"); + + assert(psbt instanceof ZcashBitGoPsbt); + assert(!(psbt instanceof ZcashIronwoodBitGoPsbt)); + assert.strictEqual(psbt.getVersion(), 4); + }); + + it("returns the v6 implementation for a complete Ironwood PSBT", function () { + const original = ZcashIronwoodBitGoPsbt.createEmpty("zcashTest", walletKeys, { + blockHeight: NU6_3_TESTNET_HEIGHT, + }); + + const psbt = ZcashPsbt.fromBytes(original.serialize(), "zcashTest"); + + assert(psbt instanceof ZcashIronwoodBitGoPsbt); + assert.strictEqual(psbt.getVersion(), 6); + }); + + it("returns the v6 implementation before a shielded output is added", function () { + const original = ZcashIronwoodBitGoPsbt.createEmpty("zcashTest", walletKeys, { + blockHeight: NU6_3_TESTNET_HEIGHT, + }); + original.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 100n }); + + const psbt = ZcashPsbt.from(original.serialize(), "zcashTest"); + + assert(psbt instanceof ZcashIronwoodBitGoPsbt); + assert.strictEqual(psbt.getVersion(), 6); + }); + + it("rejects a non-Zcash PSBT", function () { + const bitcoinPsbt = BitGoPsbt.createEmpty("bitcoin", walletKeys); + + assert.throws(() => ZcashPsbt.from(bitcoinPsbt.serialize(), "zcash"), /Zcash|branch|PSBT/i); + }); +});