diff --git a/packages/wasm-utxo/docs/zcash-v6-transaction.md b/packages/wasm-utxo/docs/zcash-v6-transaction.md index 69b5d9c2468..becbe9848e3 100644 --- a/packages/wasm-utxo/docs/zcash-v6-transaction.md +++ b/packages/wasm-utxo/docs/zcash-v6-transaction.md @@ -94,6 +94,13 @@ receiver), and its value becomes `valueBalanceIronwood` rather than a transparen `TxOut` — an indexer-side change in `buildTransaction` that reuses the existing unspent-selection / fee / change / response code unchanged. +The custodial **MPC** counterpart is implemented separately, keyless end to end: +`ZcashIronwoodBitGoPsbt.createEmptyKeyless(network, { blockHeight, expiryHeight?, lockTime? })` +builds a v6 PSBT **without any `walletKeys`** (no global xpubs — a custodial MPC shielded wallet +has no fixed-script xpub triple), `addShieldedSpends` is already keyless, and +`parseShieldedTransaction()` returns the keyless parse `{shieldedSpends, outputs, valueBalance, +fee}` for a caller that never holds the keys. + ## Capturing golden fixtures Golden fixtures live in `test/fixtures/zcash/` as a `(v6__rawtx.hex, diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts index 67b52e7ef82..05bd62e1259 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts @@ -55,6 +55,21 @@ export type CreateEmptyIronwoodWithConsensusBranchIdOptions = { expiryHeight?: number; }; +/** + * Options for creating an empty Zcash v6 (Ironwood) shielding PSBT **without** wallet keys — + * the MPC-shielded build, where no fixed-script xpub triple exists to stamp. + * + * Shape matches {@link CreateEmptyIronwoodOptions} minus `walletKeys`. + */ +export type CreateEmptyKeylessOptions = { + /** Block height to determine the consensus branch ID automatically (at/after NU6.3 activation) */ + blockHeight: number; + /** Zcash transaction expiry height */ + expiryHeight?: number; + /** Lock time (default: 0) */ + lockTime?: number; +}; + /** * A fresh ZIP-302 "no memo" memo field: the `0xf6` marker byte followed by 511 zero bytes. The * default for {@link ZcashIronwoodBitGoPsbt.addShieldedOutput}'s `memo` option. @@ -102,6 +117,31 @@ export type ZcashIronwoodParsedTransaction = shieldedSpends: ZcashShieldedSpend[]; }); +/** + * The keyless parse of a v6 (Ironwood) transaction — the MPC-shielded shape, and the + * wallet-keys-free counterpart to {@link ZcashIronwoodParsedTransaction}. The key-free + * verifier runs first: a tampered bundle throws with the failing check's `err.code`. + */ +export type ZcashShieldedParsedTransaction = { + /** The verified shielded spends, in action order. */ + shieldedSpends: ZcashShieldedSpend[]; + /** + * Every output: transparent first (tx output order), then the shielded ones (action + * order) — the same order {@link parseOutputsWithWalletKeys} reports. A transparent + * output's address is its stored `unifiedAddress` (when `addTransparentOutput` was given + * one) or the address decoded from the scriptPubKey; the wallet-ownership fields + * (`scriptId`, `derivationPath`) are always null. + */ + outputs: ZcashIronwoodParsedOutput[]; + /** The orchard bundle's value balance (Σv_spend − Σv_out); `0` without a bundle. */ + valueBalance: bigint; + /** + * Fee: total input value (transparent inputs + shielded spends) − total output value. + * Transparent input values come from the PSBT's utxo fields as-is. + */ + fee: bigint; +}; + /** The parse fields shared by both `inputKind` arms. */ type IronwoodParsedBase = ParsedTransaction & { valueBalance: bigint; @@ -123,6 +163,11 @@ type IronwoodParsedBase = ParsedTransaction, + expiry_height: Option, + ) -> Result { + zcash::v6::new_bitgo_psbt_v6_bare_at_height(network, block_height, lock_time, expiry_height) + } + /// Create a new empty PSBT with the same network parameters as an existing PSBT /// /// This is useful for reconstructing PSBTs - it copies: diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/parse.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/parse.rs index 73cd128594d..6176b40d9bc 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/parse.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/parse.rs @@ -8,8 +8,14 @@ //! [`ZcashParsedTransaction`] (per-action output indices, shielded spends, the bundle's //! value balance). The key-free verifier checks the bundle before any plaintext value is //! returned. +//! +//! [`parse_shielded_transaction`] is the wallet-keys-free counterpart: the +//! MPC-shielded shape (`shieldedSpends` / `outputs` / `valueBalance` / `fee`) for callers +//! without a fixed-script xpub triple. +use super::super::psbt_wallet_input; use super::ZcashBitGoPsbt; +use crate::address::networks::from_output_script_with_network; use crate::fixed_script_wallet::bitgo_psbt::{ BitGoPsbt, ParseTransactionError, ParsedInput, ParsedOutput, }; @@ -90,6 +96,26 @@ pub struct ZcashParsedTransaction { pub is_ironwood_v6: bool, } +/// The keyless parse of a v6 (Ironwood) transaction — the MPC-shielded shape. +/// +/// The wallet-keys-free counterpart to [`ZcashParsedTransaction`]: the verified shielded +/// side, every output, the bundle's value balance, and the fee. Transparent inputs are not +/// surfaced — a spend bundle has none, and the shielding flow's wallet-owned inputs are +/// the keyed parse's job ([`parse_transaction_with_wallet_keys`]). +#[derive(Clone, Debug)] +pub struct ZcashShieldedParsedTransaction { + /// The verified shielded spends, in action order. + pub shielded_spends: Vec, + /// Every output: transparent first (tx output order), then the shielded ones (action + /// order) — the same order [`parse_transaction_with_wallet_keys`] reports. + pub outputs: Vec, + /// The orchard bundle's value balance (Σv_spend − Σv_out); `0` when the PSBT carries + /// no Ironwood bundle. + pub value_balance: i64, + /// Fee: total input value (transparent inputs + shielded spends) − total output value. + pub fee: u64, +} + /// Errors from the Zcash-specific parse. #[derive(Debug, strum::IntoStaticStr)] pub enum ZcashParseTransactionError { @@ -100,6 +126,10 @@ pub enum ZcashParseTransactionError { /// The shielded side could not be read (no usable PCZT, an unencodable address, or a /// value overflow). Shielded(String), + /// The keyless shielded parse ([`parse_shielded_transaction`]) was handed a non-v6 + /// PSBT: a v4/Sapling PSBT's shielded side cannot be parsed without keys, so the + /// keyless surface is v6-only. + NotIronwoodV6, /// The shielded side failed key-free verification; the error code is the /// failing verifier check (`IronwoodVerifyError.`). Verify(crate::zcash::ironwood_build::IronwoodVerifyError), @@ -113,6 +143,10 @@ impl std::fmt::Display for ZcashParseTransactionError { f, "a PSBT cannot mix transparent inputs with shielded spends" ), + ZcashParseTransactionError::NotIronwoodV6 => write!( + f, + "the keyless shielded parse requires a v6 (Ironwood) PSBT" + ), ZcashParseTransactionError::Shielded(error) => write!(f, "Shielded side: {error}"), ZcashParseTransactionError::Verify(error) => { write!(f, "Shielded side failed verification: {error}") @@ -280,6 +314,114 @@ pub(crate) fn parse_transaction_with_wallet_keys( }) } +/// Parse a v6 (Ironwood) Zcash transaction without wallet keys — the MPC-shielded shape. +/// +/// The key-free verifier runs before any plaintext value leaves here: a tampered bundle +/// fails with the failing check's error code ([`ZcashParseTransactionError::Verify`]), and +/// a PCZT already consumed by `combine_ironwood_proof` fails loudly +/// ([`ZcashParseTransactionError::Shielded`]) rather than parsing as bundle-less. +/// Transparent outputs are decoded without keys: the address is the stored +/// `unified_address` (when `add_transparent_output` was given one) or the address the +/// scriptPubKey decodes to. Transparent inputs contribute only their values, taken as-is +/// from the `witness_utxo`/`non_witness_utxo` the PSBT carries — nothing here can verify +/// them against the chain, so `fee` trusts those fields; the MPC spend shapes (deshield / +/// shielded→shielded) carry no transparent inputs at all, so their `fee` rests entirely on +/// verified values. Transparent inputs are never surfaced as wallet inputs, and a PSBT +/// that mixes them with shielded spends fails loudly. +pub(crate) fn parse_shielded_transaction( + bitgo_psbt: &BitGoPsbt, +) -> Result { + let z = zcash_psbt(bitgo_psbt)?; + if !z.is_ironwood_v6() { + return Err(ZcashParseTransactionError::NotIronwoodV6); + } + let (shielded_outputs, shielded_spends, value_balance) = shielded_side(z)?; + + // One input kind, defense in depth: the adders already refuse the mix, so only a + // PSBT assembled from parts can trip this. + let psbt = bitgo_psbt.psbt(); + if !psbt.unsigned_tx.input.is_empty() && !shielded_spends.is_empty() { + return Err(ZcashParseTransactionError::MixedInputKinds); + } + + let mut total_input_value = shielded_spends.iter().try_fold(0u64, |sum, spend| { + sum.checked_add(spend.value).ok_or_else(|| { + ZcashParseTransactionError::Shielded(format!( + "action {}: shielded spend value overflow", + spend.action_index + )) + }) + })?; + for (index, (tx_input, psbt_input)) in + psbt.unsigned_tx.input.iter().zip(&psbt.inputs).enumerate() + { + let value = + psbt_wallet_input::get_output_script_and_value(psbt_input, tx_input.previous_output) + .map(|(_script, amount)| amount.to_sat()) + .map_err(|e| ParseTransactionError::Input { + index, + error: psbt_wallet_input::ParseInputError::Utxo(e), + })?; + total_input_value = total_input_value + .checked_add(value) + .ok_or(ParseTransactionError::InputValueOverflow { index })?; + } + + let mut outputs = Vec::with_capacity(psbt.unsigned_tx.output.len() + shielded_outputs.len()); + let mut total_output_value = 0u64; + for (index, tx_output) in psbt.unsigned_tx.output.iter().enumerate() { + total_output_value = total_output_value + .checked_add(tx_output.value.to_sat()) + .ok_or(ParseTransactionError::OutputValueOverflow { index })?; + outputs.push(ZcashParsedOutput { + base: ParsedOutput { + address: transparent_output_address(z, index, &tx_output.script_pubkey), + script: tx_output.script_pubkey.to_bytes(), + value: tx_output.value.to_sat(), + // No wallet keys: ownership is undecidable, so no script id, no derivation. + script_id: None, + paygo: false, + derivation_path: None, + }, + is_shielded: false, + action_index: None, + is_change: false, + }); + } + for output in shielded_outputs { + total_output_value = total_output_value.checked_add(output.base.value).ok_or( + ParseTransactionError::OutputValueOverflow { + index: outputs.len(), + }, + )?; + outputs.push(output); + } + + let fee = total_input_value + .checked_sub(total_output_value) + .ok_or(ParseTransactionError::FeeCalculation)?; + + Ok(ZcashShieldedParsedTransaction { + shielded_spends, + outputs, + value_balance, + fee, + }) +} + +/// A transparent output's address without wallet keys: the stored `unified_address` (when +/// `add_transparent_output` was given one, see [`super::propkv`]) or, failing that, the +/// address the scriptPubKey decodes to — `None` for a script with no standard address +/// form. +fn transparent_output_address( + z: &ZcashBitGoPsbt, + index: usize, + script: &miniscript::bitcoin::Script, +) -> Option { + super::propkv::get_transparent_output_unified_address(&z.psbt, index) + .or_else(|| from_output_script_with_network(script, z.network()).ok()) +} + /// Wrap the coin-neutral parsed outputs as transparent Zcash outputs. fn transparent_outputs(outputs: Vec) -> Vec { outputs diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood_spend.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood_spend.rs index b6eb2b54020..9f49036d3cc 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood_spend.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood_spend.rs @@ -427,6 +427,261 @@ fn keyless_parse_reports_verified_spends() { assert!(!spends[0].signed, "no signature yet"); } +/// A canonical testnet p2pkh script (address round-trips, see below). +const TESTNET_P2PKH_SCRIPT: [u8; 25] = [ + 0x76, 0xa9, 0x14, 0xca, 0xd2, 0x68, 0x75, 0x8c, 0x5e, 0x71, 0x49, 0x30, 0x66, 0x44, 0x6b, 0x98, + 0xe7, 0x1d, 0xf9, 0xd1, 0xd6, 0xa5, 0xca, 0x88, 0xac, +]; + +/// The script-decoded address encodes exactly the script it came from. +fn assert_address_round_trips_to_script(address: &str, script: &[u8]) { + assert_eq!( + crate::address::networks::to_output_script_with_coin(address, "tzec") + .unwrap() + .to_bytes(), + script, + "the parsed address does not decode back to the output's scriptPubKey" + ); +} + +/// The keyless parse on a bare-built deshield: the verified spend, a transparent output with +/// a script-decoded address (no keys, no stored UA), and the fee/value-balance accounting. +#[test] +fn keyless_parse_surfaces_spends_outputs_fee_and_value_balance() { + let (specs, anchor, _fvk, _ak) = keyless_fixture(); + let nu6_3 = NetworkUpgrade::Nu6_3.testnet_activation_height(); + let mut z = + ZcashBitGoPsbt::new_v6_bare_at_height(Network::ZcashTestnet, nu6_3, None, None).unwrap(); + z.add_transparent_output( + miniscript::bitcoin::ScriptBuf::from(TESTNET_P2PKH_SCRIPT.to_vec()), + 99_999_000, + None, + ) + .unwrap(); + let map = z + .add_shielded_spends(&specs[..1], &[], None, &anchor, OsRng) + .unwrap(); + + let parsed = crate::fixed_script_wallet::bitgo_psbt::zcash::parse::parse_shielded_transaction( + &BitGoPsbt::Zcash(z, Network::ZcashTestnet), + ) + .unwrap(); + + assert_eq!(parsed.shielded_spends.len(), 1); + assert_eq!( + parsed.shielded_spends[0].action_index, + map.actions.spend_action_index(0).unwrap() + ); + assert_eq!(parsed.shielded_spends[0].value, 100_000_000); + assert!( + !parsed.shielded_spends[0].rk_replaced, + "no randomizer installed yet" + ); + + assert_eq!(parsed.outputs.len(), 1); + assert_eq!(parsed.outputs[0].base.value, 99_999_000); + let address = parsed.outputs[0] + .base + .address + .as_deref() + .expect("p2pkh decodes"); + assert_address_round_trips_to_script(address, &TESTNET_P2PKH_SCRIPT); + assert!(parsed.outputs[0].base.derivation_path.is_none()); + assert_eq!(parsed.outputs[0].action_index, None); + assert!(!parsed.outputs[0].is_shielded); + + assert_eq!(parsed.fee, 1_000); + assert_eq!(parsed.value_balance, 100_000_000); +} + +/// The keyless parse agrees with the keyed parse on the shielded side: the same verified +/// spends, the same outputs, and the same fee/value-balance accounting. +#[test] +fn keyless_parse_matches_the_keyed_parse() { + let seed = "ironwood_v6_keyless_parse_agreement"; + let (z, _map, _ak, _fvk, _ask) = keyless_transfer_psbt(seed); + let psbt = BitGoPsbt::Zcash(z, Network::ZcashTestnet); + + let keyless = + crate::fixed_script_wallet::bitgo_psbt::zcash::parse::parse_shielded_transaction(&psbt) + .unwrap(); + let keyed = + crate::fixed_script_wallet::bitgo_psbt::zcash::parse::parse_transaction_with_wallet_keys( + &psbt, + &root_wallet_keys(seed), + &crate::fixed_script_wallet::ReplayProtection::new(vec![]), + &[], + ) + .unwrap(); + let keyed_spends = match &keyed.inputs { + crate::fixed_script_wallet::bitgo_psbt::zcash::parse::ZcashParsedInputs::Shielded( + spends, + ) => spends, + _ => panic!("expected the shielded input kind"), + }; + + assert_eq!(keyless.shielded_spends.len(), keyed_spends.len()); + for (keyless_spend, keyed_spend) in keyless.shielded_spends.iter().zip(keyed_spends) { + assert_eq!(keyless_spend.action_index, keyed_spend.action_index); + assert_eq!(keyless_spend.value, keyed_spend.value); + assert_eq!(keyless_spend.recipient, keyed_spend.recipient); + assert_eq!(keyless_spend.nullifier, keyed_spend.nullifier); + assert_eq!(keyless_spend.cmx, keyed_spend.cmx); + assert_eq!(keyless_spend.position, keyed_spend.position); + assert_eq!(keyless_spend.address, keyed_spend.address); + assert_eq!(keyless_spend.rk_replaced, keyed_spend.rk_replaced); + assert_eq!(keyless_spend.signed, keyed_spend.signed); + } + + assert_eq!(keyless.outputs.len(), keyed.outputs.len()); + for (keyless_output, keyed_output) in keyless.outputs.iter().zip(&keyed.outputs) { + assert_eq!(keyless_output.base.address, keyed_output.base.address); + assert_eq!(keyless_output.base.value, keyed_output.base.value); + assert_eq!(keyless_output.action_index, keyed_output.action_index); + assert_eq!(keyless_output.is_shielded, keyed_output.is_shielded); + assert_eq!(keyless_output.is_change, keyed_output.is_change); + } + + assert_eq!(keyless.value_balance, keyed.value_balance); + assert_eq!(keyless.fee, keyed.miner_fee); +} + +/// The keyless parse also accounts for a transparent-only v6 PSBT (the shielding flow's +/// shape): input values come from the PSBT's utxo fields, no keys needed, and no bundle +/// means no spends and a zero value balance. +#[test] +fn keyless_parse_accounts_for_transparent_only_v6() { + let seed = "ironwood_v6_keyless_transparent_only"; + let wallet_keys = root_wallet_keys(seed); + let nu6_3 = NetworkUpgrade::Nu6_3.testnet_activation_height(); + let mut z = + ZcashBitGoPsbt::new_v6_bare_at_height(Network::ZcashTestnet, nu6_3, None, None).unwrap(); + z.add_transparent_output( + miniscript::bitcoin::ScriptBuf::from(TESTNET_P2PKH_SCRIPT.to_vec()), + 199_999_000, + None, + ) + .unwrap(); + let mut psbt = BitGoPsbt::Zcash(z, Network::ZcashTestnet); + psbt.add_wallet_input( + Txid::from_byte_array([0x51u8; 32]), + 0, + 200_000_000, + &wallet_keys, + ScriptId { chain: 0, index: 0 }, + WalletInputOptions::default(), + ) + .unwrap(); + + let parsed = + crate::fixed_script_wallet::bitgo_psbt::zcash::parse::parse_shielded_transaction(&psbt) + .unwrap(); + assert!(parsed.shielded_spends.is_empty()); + assert_eq!(parsed.value_balance, 0); + assert_eq!(parsed.fee, 1_000); + assert_eq!(parsed.outputs.len(), 1); + assert_eq!(parsed.outputs[0].base.value, 199_999_000); + let address = parsed.outputs[0] + .base + .address + .as_deref() + .expect("p2pkh decodes"); + assert_address_round_trips_to_script(address, &TESTNET_P2PKH_SCRIPT); +} + +/// The keyless parse is v6-only: a v4/Sapling PSBT is rejected with the typed code, never +/// silently reported as having no shielded side. +#[test] +fn keyless_parse_rejects_a_v4_psbt() { + let wallet_keys = root_wallet_keys("ironwood_v6_keyless_v4_guard"); + let z = ZcashBitGoPsbt::new( + Network::ZcashTestnet, + &wallet_keys, + 0xC2D6D0B4, // NU5 + Some(4), + Some(0), + Some(ZCASH_SAPLING_VERSION_GROUP_ID), + Some(0), + ); + let error = crate::fixed_script_wallet::bitgo_psbt::zcash::parse::parse_shielded_transaction( + &BitGoPsbt::Zcash(z, Network::ZcashTestnet), + ) + .unwrap_err(); + assert_eq!( + crate::error::WasmErrorCode::code(&error), + "ZcashParseTransactionError.NotIronwoodV6" + ); +} + +/// Defense in depth: the keyless parse refuses a hand-assembled PSBT that rides shielded +/// spends alongside transparent inputs — the adders reject the mix, so only bytes +/// assembled outside this crate can reach the guard. +#[test] +fn keyless_parse_rejects_transparent_inputs_alongside_spends() { + let seed = "ironwood_v6_keyless_parse_mixed"; + // A spend bundle built normally... + let (spend_psbt, _map, _ak, _fvk, _ask) = keyless_deshield_psbt(seed); + let pczt_bytes = + crate::fixed_script_wallet::bitgo_psbt::zcash::propkv::get_ironwood_pczt(&spend_psbt.psbt) + .unwrap(); + // ...transplanted onto a PSBT that also carries a transparent input. + let wallet_keys = root_wallet_keys(seed); + let nu6_3 = NetworkUpgrade::Nu6_3.testnet_activation_height(); + let mut mixed = + BitGoPsbt::new_zcash_v6_at_height(Network::ZcashTestnet, &wallet_keys, nu6_3, None, None) + .unwrap(); + mixed + .add_wallet_input( + Txid::from_byte_array([0x52u8; 32]), + 0, + 200_000_000, + &wallet_keys, + ScriptId { chain: 0, index: 0 }, + WalletInputOptions::default(), + ) + .unwrap(); + let BitGoPsbt::Zcash(mut z, _) = mixed else { + panic!("expected Zcash PSBT"); + }; + crate::fixed_script_wallet::bitgo_psbt::zcash::propkv::set_ironwood_pczt( + &mut z.psbt, + pczt_bytes, + ); + + let error = crate::fixed_script_wallet::bitgo_psbt::zcash::parse::parse_shielded_transaction( + &BitGoPsbt::Zcash(z, Network::ZcashTestnet), + ) + .unwrap_err(); + assert_eq!( + crate::error::WasmErrorCode::code(&error), + "ZcashParseTransactionError.MixedInputKinds" + ); +} + +/// The keyless parse surfaces the key-free verifier's failing check for a tampered bundle +/// (`IronwoodVerifyError.`), exactly like `verify_shielded_bundle` does. +#[test] +fn keyless_parse_fails_with_the_verifier_check_for_a_tampered_bundle() { + let seed = "ironwood_v6_keyless_parse_tampered"; + let (mut z, map, _ak, _fvk, _ask) = keyless_deshield_psbt(seed); + let bytes = + crate::fixed_script_wallet::bitgo_psbt::zcash::propkv::get_ironwood_pczt(&z.psbt).unwrap(); + // The spend's plaintext value no longer commits to its `cv_net`. + let spend_action = map.actions.spend_action_index(0).unwrap(); + let tampered = + crate::zcash::ironwood_pczt::with_spend_value(&bytes, spend_action, 100_000_001).unwrap(); + crate::fixed_script_wallet::bitgo_psbt::zcash::propkv::set_ironwood_pczt(&mut z.psbt, tampered); + + let error = crate::fixed_script_wallet::bitgo_psbt::zcash::parse::parse_shielded_transaction( + &BitGoPsbt::Zcash(z, Network::ZcashTestnet), + ) + .unwrap_err(); + assert_eq!( + crate::error::WasmErrorCode::code(&error), + "IronwoodVerifyError.CvNet" + ); +} + /// One input kind, shielded side: `add_shielded_spends` rejects a PSBT that already carries /// transparent inputs, with the typed error code. #[test] diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/v6.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/v6.rs index 3b250027bf1..85465c0b981 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/v6.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/v6.rs @@ -267,6 +267,53 @@ fn new_v6_at_height_rejects_pre_nu6_3_height() { .is_ok()); } +/// The bare (xpub-free) v6 constructor builds the same PSBT shape as the keyed one — minus the +/// informational global xpubs — and round-trips through a serialize/deserialize. +#[test] +fn new_v6_bare_at_height_builds_a_valid_psbt_without_xpubs() { + let nu6_3 = NetworkUpgrade::Nu6_3.testnet_activation_height(); + let z = + ZcashBitGoPsbt::new_v6_bare_at_height(Network::ZcashTestnet, nu6_3, Some(123), Some(456)) + .unwrap(); + assert!(z.is_ironwood_v6()); + assert_eq!( + z.version_group_id, + Some(crate::zcash::transaction::ZCASH_IRONWOOD_VERSION_GROUP_ID) + ); + assert_eq!(z.expiry_height, Some(456)); + assert_eq!(z.psbt.unsigned_tx.lock_time.to_consensus_u32(), 123); + assert_eq!( + crate::fixed_script_wallet::bitgo_psbt::zcash::propkv::get_zec_v6_consensus_branch_id( + &z.psbt + ), + crate::zcash::branch_id_for_height(nu6_3, false) + ); + assert!(z.sapling_fields.is_empty(), "v6 carries no Sapling fields"); + assert!( + z.psbt.xpub.is_empty(), + "the bare constructor stamps no xpubs" + ); + + let round = + ZcashBitGoPsbt::deserialize(&z.serialize().unwrap(), Network::ZcashTestnet).unwrap(); + assert_eq!(round.version_group_id, z.version_group_id); + assert_eq!(round.expiry_height, Some(456)); + assert!(round.psbt.xpub.is_empty()); +} + +/// The bare constructor enforces the NU6.3 floor, exactly like the keyed one. +#[test] +fn new_v6_bare_at_height_rejects_pre_nu6_3_height() { + let nu6_3 = NetworkUpgrade::Nu6_3.testnet_activation_height(); + let err = ZcashBitGoPsbt::new_v6_bare_at_height(Network::ZcashTestnet, nu6_3 - 1, None, None) + .unwrap_err(); + assert!(err.contains("NU6.3"), "unexpected error: {err}"); + // The activation height itself is accepted. + assert!( + ZcashBitGoPsbt::new_v6_bare_at_height(Network::ZcashTestnet, nu6_3, None, None).is_ok() + ); +} + /// `verify_v6_signature_with_pub`/`_with_xpub` on a transparent-only v6 PSBT (no shielded /// output ever added): a PR review comment on the Ironwood transparent-only PSBT work noted /// that `getId`/`verifySignature` still required a PCZT and would throw for a pure diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/v6.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/v6.rs index cdc78c6212d..f61dd747c79 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/v6.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/v6.rs @@ -85,6 +85,24 @@ impl std::fmt::Display for VerifyV6SignatureError { crate::impl_wasm_error_code!(VerifyV6SignatureError); +/// Resolve the consensus branch id for `block_height`, rejecting heights before NU6.3 +/// activation: a v6 transaction stamped with a pre-NU6.3 branch id is only rejected at +/// broadcast, long after signing. +fn v6_branch_id_at_height(network: crate::Network, block_height: u32) -> Result { + let is_mainnet = matches!(network, crate::Network::Zcash); + let network_name = if is_mainnet { "mainnet" } else { "testnet" }; + let nu6_3 = crate::zcash::NetworkUpgrade::Nu6_3.activation_height(is_mainnet); + if block_height < nu6_3 { + return Err(format!( + "Block height {block_height} is before NU6.3 (Ironwood) activation ({nu6_3}) on \ + {network_name}; v6 transactions are not valid before then" + )); + } + crate::zcash::branch_id_for_height(block_height, is_mainnet).ok_or_else(|| { + format!("Block height {block_height} is before Overwinter activation on {network_name}") + }) +} + impl ZcashBitGoPsbt { /// Create an empty Zcash **v6 (Ironwood)** shielding PSBT, with the consensus branch id resolved /// from `block_height`, which must be at or after NU6.3 activation. @@ -98,23 +116,7 @@ impl ZcashBitGoPsbt { lock_time: Option, expiry_height: Option, ) -> Result { - let is_mainnet = matches!(network, crate::Network::Zcash); - let network_name = if is_mainnet { "mainnet" } else { "testnet" }; - let nu6_3 = crate::zcash::NetworkUpgrade::Nu6_3.activation_height(is_mainnet); - if block_height < nu6_3 { - return Err(format!( - "Block height {block_height} is before NU6.3 (Ironwood) activation ({nu6_3}) on \ - {network_name}; v6 transactions are not valid before then" - )); - } - let consensus_branch_id = crate::zcash::branch_id_for_height(block_height, is_mainnet) - .ok_or_else(|| { - format!( - "Block height {} is before Overwinter activation on {}", - block_height, - if is_mainnet { "mainnet" } else { "testnet" } - ) - })?; + let consensus_branch_id = v6_branch_id_at_height(network, block_height)?; Ok(Self::new_v6( network, wallet_keys, @@ -124,6 +126,24 @@ impl ZcashBitGoPsbt { )) } + /// Create an empty Zcash **v6 (Ironwood)** shielding PSBT without embedding any xpubs, with + /// the consensus branch id resolved from `block_height`, which must be at or after NU6.3 + /// activation. The keyless counterpart to [`Self::new_v6_at_height`]. + pub fn new_v6_bare_at_height( + network: crate::Network, + block_height: u32, + lock_time: Option, + expiry_height: Option, + ) -> Result { + let consensus_branch_id = v6_branch_id_at_height(network, block_height)?; + Ok(Self::new_v6_bare( + network, + consensus_branch_id, + lock_time, + expiry_height, + )) + } + /// Create an empty Zcash **v6 (Ironwood)** shielding PSBT with an explicit consensus branch id. pub fn new_v6( network: crate::Network, @@ -897,6 +917,21 @@ pub(crate) fn new_bitgo_psbt_v6_at_height( )) } +/// [`BitGoPsbt::new_zcash_v6_bare_at_height`]: an empty v6 (Ironwood) PSBT without embedded +/// xpubs, with the consensus branch id resolved from block height. Delegates to +/// [`ZcashBitGoPsbt::new_v6_bare_at_height`]. +pub(crate) fn new_bitgo_psbt_v6_bare_at_height( + network: Network, + block_height: u32, + lock_time: Option, + expiry_height: Option, +) -> Result { + Ok(BitGoPsbt::Zcash( + ZcashBitGoPsbt::new_v6_bare_at_height(network, block_height, lock_time, expiry_height)?, + network, + )) +} + /// The caller-supplied Unified Address for a transparent output, if `add_transparent_output` /// was given one for `output_index` — `None` for non-Zcash PSBTs or outputs without one. /// diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/zcash_psbt.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/zcash_psbt.rs index c26626896ad..5fccfbe28ab 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/zcash_psbt.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/zcash_psbt.rs @@ -20,7 +20,7 @@ use crate::fixed_script_wallet::bitgo_psbt::zcash::ironwood_spend::{ }; use crate::fixed_script_wallet::bitgo_psbt::zcash::parse::{ ZcashParsedInput, ZcashParsedInputs, ZcashParsedOutput, ZcashParsedTransaction, - ZcashShieldedSpend, + ZcashShieldedParsedTransaction, ZcashShieldedSpend, }; use crate::fixed_script_wallet::bitgo_psbt::zcash::IronwoodOutputRequest; use crate::wasm::bip32::WasmBIP32; @@ -211,6 +211,38 @@ impl BitGoPsbt { }) } + /// Create an empty Zcash **v6 (Ironwood)** shielding PSBT **without embedding any xpubs**, + /// with the consensus branch id resolved from `block_height` (which must be at or after + /// NU6.3 activation). + /// + /// The keyless MPC-shielded constructor: a custodial MPC shielded wallet has no + /// fixed-script xpub triple, so there is nothing to stamp — the xpubs + /// `create_empty_zcash_v6_at_height` embeds are purely informational metadata, and every + /// other v6 method reads/writes only the proprietary-map fields this constructor also + /// sets. Transparent inputs/outputs are added with the usual `add_wallet_input` / + /// `add_wallet_output`; the shielded side uses the dedicated `add_ironwood_output` / + /// `add_ironwood_shielded_spends` methods; `parse_shielded_transaction` surfaces what was + /// built without keys. + pub fn create_empty_zcash_v6_bare_at_height( + network: &str, + block_height: u32, + lock_time: Option, + expiry_height: Option, + ) -> Result { + let network = parse_network(network)?; + let psbt = crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::new_zcash_v6_bare_at_height( + network, + block_height, + lock_time, + expiry_height, + ) + .map_err(|e| WasmUtxoError::new(&e))?; + Ok(BitGoPsbt { + psbt, + first_rounds: HashMap::new(), + }) + } + /// Get the Zcash version group ID (returns None for non-Zcash PSBTs) pub fn version_group_id(&self) -> Option { use crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt as InnerBitGoPsbt; @@ -884,6 +916,30 @@ impl BitGoPsbt { .map_err(WasmUtxoError::from)? .try_to_js_value() } + + /// The keyless Zcash parse — `parse_shielded_transaction`: what a caller without wallet + /// keys can account for on a v6 (Ironwood) PSBT. + /// + /// Returns `{shieldedSpends, outputs, valueBalance, fee}`: the verified shielded spends + /// (the key-free verifier runs first), every output (transparent + shielded, with + /// `isShielded`/`actionIndex`/`isChange`), the bundle's value balance, and the fee. + /// Transparent outputs carry the stored `unified_address` when one was set, else the + /// address decoded from the scriptPubKey; transparent inputs are counted toward the fee + /// but not surfaced. Their values come from the PSBT's utxo fields as-is — nothing here + /// can verify them against the chain — so a transparent-input parse's `fee` trusts + /// those fields; the MPC spend shapes carry no transparent inputs, so their `fee` is + /// fully verified. + /// + /// Throws `ZcashParseTransactionError.NotIronwoodV6` on a non-v6 PSBT, + /// `ZcashParseTransactionError.MixedInputKinds` when transparent inputs ride alongside + /// shielded spends, and `ZcashParseTransactionError.Shielded` once + /// `combine_ironwood_proof` has consumed the PCZT. + pub fn parse_shielded_transaction(&self) -> Result { + crate::fixed_script_wallet::bitgo_psbt::zcash::parse::parse_shielded_transaction( + &self.psbt, + )? + .try_to_js_value() + } } impl TryIntoJsValue for VerifiedOutput { @@ -1156,3 +1212,14 @@ impl TryIntoJsValue for ZcashParsedTransaction { ) } } + +impl TryIntoJsValue for ZcashShieldedParsedTransaction { + fn try_to_js_value(&self) -> Result { + js_obj!( + "shieldedSpends" => self.shielded_spends.clone(), + "outputs" => self.outputs.clone(), + "valueBalance" => self.value_balance, + "fee" => self.fee + ) + } +} diff --git a/packages/wasm-utxo/src/zcash/ironwood_construct.rs b/packages/wasm-utxo/src/zcash/ironwood_construct.rs index c015ddc3388..40b91c2c3f1 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_construct.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_construct.rs @@ -1034,41 +1034,48 @@ mod tests { /// Randomness: two builds of the same request produce different action orders, `rcv`s /// and `rseed`s — linkability hinges on the Constructor randomizing everything it can. + /// + /// Deterministic: each build is driven by its own fixed seed, so every assertion here + /// is either always true or always false — no run-to-run flake. (The OsRng variant of + /// this test failed with probability 2^-7: eight fresh shuffles all colliding on the + /// 2! possible orders.) #[test] fn two_builds_randomize_order_rcvs_and_rseeds() { + use rand::rngs::StdRng; + use rand::SeedableRng; + let (specs, anchor, fvk, _ak) = fixture(); let outs = [ wallet_output(&fvk, 100, 30_000_000), wallet_output(&fvk, 101, 30_000_000), ]; - let build = || { + let build = |seed: u64| { + let mut rng = StdRng::seed_from_u64(seed); let (bundle, map) = - construct_spend_pczt_keyless(&specs, &outs, &anchor, OsRng).unwrap(); + construct_spend_pczt_keyless(&specs, &outs, &anchor, &mut rng).unwrap(); (serialize_pczt(&bundle).unwrap(), map) }; - // The action ORDER differs: the maps disagree on where spend 0 landed. 2! orders are - // possible (a collision has probability 1/2 per pair), so retry a few times. + // The action ORDER differs: some seed pair disagrees on where spend 0 landed. 2! + // orders are possible, so try a few fixed seed pairs and require one to disagree. let mut order_differs = false; - let mut first: Option<(Vec, IronwoodActionMap)> = None; - for _ in 0..8 { - let (bytes, map) = build(); - if let Some((_, ref map_a)) = first { - if (0..2).any(|i| map_a.spend_action_index(i) != map.spend_action_index(i)) - || (0..2).any(|i| map_a.output_action_index(i) != map.output_action_index(i)) - { - order_differs = true; - } - } else { - first = Some((bytes, map)); + for pair in 0..8u64 { + let (_, map_a) = build(0xC511_0000 + 2 * pair); + let (_, map_b) = build(0xC511_0000 + 2 * pair + 1); + if (0..2).any(|i| map_a.spend_action_index(i) != map_b.spend_action_index(i)) + || (0..2).any(|i| map_a.output_action_index(i) != map_b.output_action_index(i)) + { + order_differs = true; + break; } } assert!( order_differs, "the shuffle must reorder actions across builds" ); - // The rseeds differ regardless of order: compare the ciphertexts of two builds. - let (a, _) = build(); - let (b, _) = build(); + // The rseeds differ regardless of order: two differently-seeded builds differ + // byte-for-byte. + let (a, _) = build(0xC511_0000 + 100); + let (b, _) = build(0xC511_0000 + 101); assert_ne!( a, b, "two builds of the same request must differ byte-for-byte" diff --git a/packages/wasm-utxo/test/fixedScript/zcashIronwoodKeyless.ts b/packages/wasm-utxo/test/fixedScript/zcashIronwoodKeyless.ts new file mode 100644 index 00000000000..8a986ff004e --- /dev/null +++ b/packages/wasm-utxo/test/fixedScript/zcashIronwoodKeyless.ts @@ -0,0 +1,196 @@ +import assert from "node:assert"; +import { describe, it } from "mocha"; + +import { + IRONWOOD_VERSION_GROUP_ID, + ZcashBitGoPsbt, +} from "../../js/fixedScriptWallet/ZcashBitGoPsbt.js"; +import { + ZcashIronwoodBitGoPsbt, + type CreateEmptyKeylessOptions, + type ZcashShieldedParsedTransaction, +} from "../../js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.js"; +import { ZcashIronwoodWitness } from "../../js/fixedScriptWallet/ZcashIronwoodWitness.js"; +import { getWalletKeysForSeed } from "../../js/testutils/index.js"; +import type { ZcashShieldedSpendRequest } from "../../js/fixedScriptWallet/ZcashIronwoodTypes.js"; +import { + assertErrorCode, + freshPsbt, + hex, + loadFixture, + type IronwoodSpendFixture, +} from "./zcashIronwoodSpend.util.js"; + +const NU6_3_TESTNET_HEIGHT = 4134000; + +/** The fixture's spend request, with witnesses rebuilt (and re-validated) from the raw path. */ +function spendRequests(fixture: IronwoodSpendFixture): ZcashShieldedSpendRequest[] { + return fixture.request.spends.map((spend) => ({ + recipient: hex(spend.recipient), + value: BigInt(spend.value), + rho: hex(spend.rho), + rseed: hex(spend.rseed), + cmx: hex(spend.cmx), + witness: ZcashIronwoodWitness.build( + hex(spend.cmx), + spend.witness.position, + spend.witness.authPath.map(hex), + hex(fixture.request.anchor), + ), + nullifier: hex(spend.nullifier), + })); +} + +describe("ZcashIronwoodBitGoPsbt keyless MPC surface", function () { + const createOptions = (blockHeight: number): CreateEmptyKeylessOptions => ({ blockHeight }); + + describe("createEmptyKeyless (no wallet keys)", function () { + it("builds a valid v6 (Ironwood) PSBT that round-trips", function () { + const psbt = ZcashIronwoodBitGoPsbt.createEmptyKeyless("zcashTest", { + blockHeight: NU6_3_TESTNET_HEIGHT, + lockTime: 123, + expiryHeight: 456, + }); + assert.strictEqual(psbt.getVersion(), 6); + assert.strictEqual(psbt.versionGroupId, IRONWOOD_VERSION_GROUP_ID); + assert.strictEqual(psbt.expiryHeight, 456); + + const round = ZcashIronwoodBitGoPsbt.fromBytes(psbt.serialize(), "zcashTest"); + assert.strictEqual(round.versionGroupId, IRONWOOD_VERSION_GROUP_ID); + assert.strictEqual(round.expiryHeight, 456); + }); + + it("rejects a block height before NU6.3 activation", function () { + assert.throws( + () => + ZcashIronwoodBitGoPsbt.createEmptyKeyless("zcashTest", { + blockHeight: NU6_3_TESTNET_HEIGHT - 1, + }), + /NU6\.3/, + ); + }); + + it("drives a full keyless spend build without ever needing keys", function () { + const fixture = loadFixture("deshield"); + const psbt = ZcashIronwoodBitGoPsbt.createEmptyKeyless( + "zcashTest", + createOptions(fixture.blockHeight), + ); + + psbt.addTransparentOutput( + hex(fixture.request.transparentOutput.script), + BigInt(fixture.request.transparentOutput.value), + ); + assert.throws(() => psbt.parseShieldedTransaction(), /outputs exceed inputs/); + + const map = psbt.addShieldedSpends(spendRequests(fixture), [], { + anchor: hex(fixture.request.anchor), + }); + assert.strictEqual(map.spendActionIndices.length, 1); + assert.strictEqual(psbt.zip317ConventionalFee(), BigInt(fixture.zip317ConventionalFee)); + }); + }); + + describe("parseShieldedTransaction", function () { + it("parses a keyless deshield build: the verified spend, the transparent output, fee, value balance", function () { + const fixture = loadFixture("deshield"); + const psbt = ZcashIronwoodBitGoPsbt.createEmptyKeyless( + "zcashTest", + createOptions(fixture.blockHeight), + ); + psbt.addTransparentOutput( + hex(fixture.request.transparentOutput.script), + BigInt(fixture.request.transparentOutput.value), + ); + const map = psbt.addShieldedSpends(spendRequests(fixture), [], { + anchor: hex(fixture.request.anchor), + }); + + const parsed: ZcashShieldedParsedTransaction = psbt.parseShieldedTransaction(); + assert.strictEqual(parsed.shieldedSpends.length, 1); + assert.strictEqual(parsed.shieldedSpends[0].actionIndex, map.spendActionIndices[0]); + assert.strictEqual(parsed.shieldedSpends[0].value, 100_000_000n); + assert.strictEqual(parsed.shieldedSpends[0].randomizer, "placeholder"); + assert.strictEqual(parsed.shieldedSpends[0].signed, false); + assert.match(parsed.shieldedSpends[0].address, /^utest1/); + + // The fixture's transparent payout script (`76a91488ac`) is not a standard address + // script, so the keyless parse reports `address: null` rather than guessing. + assert.strictEqual(parsed.outputs.length, 1); + assert.strictEqual(parsed.outputs[0].isShielded, false); + assert.strictEqual(parsed.outputs[0].actionIndex, null); + assert.strictEqual(parsed.outputs[0].value, 99_990_000n); + assert.strictEqual(parsed.outputs[0].address, null); + assert.strictEqual(parsed.outputs[0].scriptId, null); + assert.strictEqual(parsed.outputs[0].derivationPath, null); + + assert.strictEqual(parsed.fee, 10_000n); + assert.strictEqual(parsed.valueBalance, 100_000_000n); + }); + + it("reports the change output and the bundle accounting for a build with change", function () { + const spendFixture = loadFixture("deshield"); + const outputFixture = loadFixture("transfer"); + const psbt = ZcashIronwoodBitGoPsbt.createEmptyKeyless( + "zcashTest", + createOptions(spendFixture.blockHeight), + ); + psbt.addShieldedSpends( + spendRequests(spendFixture), + [ + { + recipient: hex(outputFixture.request.outputs[0].recipient), + amount: 50_000_000n, + memo: hex(outputFixture.request.outputs[0].memo), + }, + ], + { + anchor: hex(spendFixture.request.anchor), + change: { amount: 49_990_000n, spendIndex: 0 }, + }, + ); + + const parsed = psbt.parseShieldedTransaction(); + assert.strictEqual(parsed.outputs.length, 2); + const change = parsed.outputs.filter((output) => output.isChange); + assert.strictEqual(change.length, 1); + assert.strictEqual(change[0].isShielded, true); + assert.strictEqual(change[0].value, 49_990_000n); + assert.match(change[0].address, /^utest1/); + assert.notStrictEqual(change[0].actionIndex, null); + + // 100M in (one spend), 50M + 49_990_000 out → the ZIP-317 conventional fee. + assert.strictEqual(parsed.fee, 10_000n); + assert.strictEqual(parsed.fee, psbt.zip317ConventionalFee()); + assert.strictEqual(parsed.valueBalance, 10_000n); + }); + + it("agrees with the wallet-keys parse on the fixture PSBTs", function () { + const walletKeys = getWalletKeysForSeed("ironwood-ts"); + for (const name of ["deshield", "transfer"] as const) { + const fixture = loadFixture(name); + const psbt = freshPsbt(fixture); + const keyless = psbt.parseShieldedTransaction(); + const keyed = psbt.parseTransactionWithWalletKeys(walletKeys, { + replayProtection: { publicKeys: [] }, + }); + + assert.deepStrictEqual(keyless.shieldedSpends, keyed.shieldedSpends, name); + assert.deepStrictEqual(keyless.outputs, keyed.outputs, name); + assert.strictEqual(keyless.valueBalance, keyed.valueBalance, name); + assert.strictEqual(keyless.fee, keyed.minerFee, name); + } + }); + + it("rejects a v4 (Sapling) PSBT with the typed code", function () { + const walletKeys = getWalletKeysForSeed("ironwood-ts"); + // The keyless parse is v6-only: a v4 (Sapling) PSBT, reached through the internal + // wasm handle, throws the typed error instead of silently reporting no shielded side. + const v4 = ZcashBitGoPsbt.createEmpty("zcashTest", walletKeys, { blockHeight: 2_000_000 }); + assertErrorCode( + () => ZcashIronwoodBitGoPsbt.fromWasm(v4.wasm).parseShieldedTransaction(), + "ZcashParseTransactionError.NotIronwoodV6", + ); + }); + }); +});