From 906ad8181a646417cd4eebf8f1f4896c236599a5 Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Thu, 22 Oct 2026 14:47:57 +0530 Subject: [PATCH] refactor(wasm-utxo): add zcash wasm binding and js conversion Ticket: CHSLD-2014 --- packages/wasm-utxo/src/inspect/mod.rs | 3 +- packages/wasm-utxo/src/inspect/psbt.rs | 442 +---------- packages/wasm-utxo/src/inspect/zcash.rs | 437 +++++++++++ .../src/wasm/fixed_script_wallet/mod.rs | 610 +-------------- .../wasm/fixed_script_wallet/zcash_psbt.rs | 692 ++++++++++++++++++ .../wasm-utxo/src/wasm/try_from_js_value.rs | 66 -- 6 files changed, 1145 insertions(+), 1105 deletions(-) create mode 100644 packages/wasm-utxo/src/inspect/zcash.rs create mode 100644 packages/wasm-utxo/src/wasm/fixed_script_wallet/zcash_psbt.rs diff --git a/packages/wasm-utxo/src/inspect/mod.rs b/packages/wasm-utxo/src/inspect/mod.rs index 3a7e558a686..9e7cd426809 100644 --- a/packages/wasm-utxo/src/inspect/mod.rs +++ b/packages/wasm-utxo/src/inspect/mod.rs @@ -1,10 +1,11 @@ mod node; mod psbt; mod psbt_raw; +mod zcash; pub use node::{Buffer, Node, Primitive}; pub use psbt::{ parse_psbt_bytes_with_network, parse_tx_bytes_with_network, psbt_to_node, tx_to_node, - zcash_psbt_to_node, zcash_tx_to_node, }; pub use psbt_raw::parse_psbt_bytes_raw_with_network; +pub use zcash::{zcash_psbt_to_node, zcash_tx_to_node}; diff --git a/packages/wasm-utxo/src/inspect/psbt.rs b/packages/wasm-utxo/src/inspect/psbt.rs index 60b2e06332a..5ae1791fa8e 100644 --- a/packages/wasm-utxo/src/inspect/psbt.rs +++ b/packages/wasm-utxo/src/inspect/psbt.rs @@ -8,7 +8,7 @@ use crate::fixed_script_wallet::bitgo_psbt::{ BitGoKeyValue, ProprietaryKeySubtype, ZcashBitGoPsbt, BITGO, }; use crate::networks::Network; -use crate::zcash::transaction::{decode_zcash_transaction_parts, ZcashTransactionParts}; +use crate::zcash::transaction::decode_zcash_transaction_parts; pub use super::node::{Node, Primitive}; @@ -184,7 +184,7 @@ fn raw_proprietary_to_node( prop_node } -fn proprietary_to_nodes( +pub(super) fn proprietary_to_nodes( proprietary: &std::collections::BTreeMap>, ) -> Vec { proprietary @@ -281,7 +281,7 @@ fn tx_input_to_node(input: &crate::bitcoin::TxIn, index: usize) -> Node { input_node } -fn tx_inputs_to_node(inputs: &[crate::bitcoin::TxIn]) -> Node { +pub(super) fn tx_inputs_to_node(inputs: &[crate::bitcoin::TxIn]) -> Node { let mut inputs_node = Node::new("inputs", Primitive::U64(inputs.len() as u64)); for (i, input) in inputs.iter().enumerate() { inputs_node.add_child(tx_input_to_node(input, i)); @@ -306,7 +306,7 @@ fn tx_output_to_node(output: &crate::bitcoin::TxOut, index: usize, network: Netw output_node } -fn tx_outputs_to_node(outputs: &[crate::bitcoin::TxOut], network: Network) -> Node { +pub(super) fn tx_outputs_to_node(outputs: &[crate::bitcoin::TxOut], network: Network) -> Node { let mut outputs_node = Node::new("outputs", Primitive::U64(outputs.len() as u64)); for (i, output) in outputs.iter().enumerate() { outputs_node.add_child(tx_output_to_node(output, i, network)); @@ -411,7 +411,10 @@ fn psbt_input_to_node(input: &crate::bitcoin::psbt::Input, index: usize, network input_node } -fn psbt_inputs_to_node(inputs: &[crate::bitcoin::psbt::Input], network: Network) -> Node { +pub(super) fn psbt_inputs_to_node( + inputs: &[crate::bitcoin::psbt::Input], + network: Network, +) -> Node { let mut inputs_node = Node::new("inputs", Primitive::U64(inputs.len() as u64)); for (i, input) in inputs.iter().enumerate() { inputs_node.add_child(psbt_input_to_node(input, i, network)); @@ -444,7 +447,7 @@ fn psbt_output_to_node(output: &crate::bitcoin::psbt::Output, index: usize) -> N output_node } -fn psbt_outputs_to_node(outputs: &[crate::bitcoin::psbt::Output]) -> Node { +pub(super) fn psbt_outputs_to_node(outputs: &[crate::bitcoin::psbt::Output]) -> Node { let mut outputs_node = Node::new("outputs", Primitive::U64(outputs.len() as u64)); for (i, output) in outputs.iter().enumerate() { outputs_node.add_child(psbt_output_to_node(output, i)); @@ -498,261 +501,6 @@ pub fn tx_to_node(tx: &Transaction, network: Network) -> Node { tx_node } -/// Convert a Zcash transaction (ZcashTransactionParts) to a Node tree -pub fn zcash_tx_to_node(parts: &ZcashTransactionParts, network: Network) -> Node { - let tx = &parts.transaction; - let mut tx_node = Node::new("tx", Primitive::None); - - // Zcash-specific fields first - if parts.is_overwintered { - tx_node.add_child(Node::new("is_overwintered", Primitive::Boolean(true))); - } - if let Some(vgid) = parts.version_group_id { - tx_node.add_child(Node::new("version_group_id", Primitive::U32(vgid))); - } - if let Some(expiry) = parts.expiry_height { - tx_node.add_child(Node::new("expiry_height", Primitive::U32(expiry))); - } - if !parts.sapling_fields.is_empty() { - tx_node.add_child(Node::new( - "sapling_fields", - Primitive::Buffer(parts.sapling_fields.clone()), - )); - } - - // Standard transaction fields (reuse helpers) - tx_node.add_child(Node::new("version", Primitive::I32(tx.version.0))); - tx_node.add_child(Node::new( - "lock_time", - Primitive::U32(tx.lock_time.to_consensus_u32()), - )); - tx_node.add_child(Node::new( - "txid", - Primitive::String(tx.compute_txid().to_string()), - )); - tx_node.add_child(Node::new( - "ntxid", - Primitive::String(tx.compute_ntxid().to_string()), - )); - tx_node.add_child(Node::new( - "wtxid", - Primitive::String(tx.compute_wtxid().to_string()), - )); - tx_node.add_child(tx_inputs_to_node(&tx.input)); - tx_node.add_child(tx_outputs_to_node(&tx.output, network)); - - tx_node -} - -/// Convert a ZcashBitGoPsbt to a Node tree -pub fn zcash_psbt_to_node(zcash_psbt: &ZcashBitGoPsbt, network: Network) -> Node { - if zcash_psbt.is_ironwood_v6() { - return ironwood_v6_psbt_to_node(zcash_psbt, network); - } - - let psbt = &zcash_psbt.psbt; - let mut psbt_node = Node::new("psbt", Primitive::None); - - // Zcash-specific fields at PSBT level - if let Some(vgid) = zcash_psbt.version_group_id { - psbt_node.add_child(Node::new("version_group_id", Primitive::U32(vgid))); - } - if let Some(expiry) = zcash_psbt.expiry_height { - psbt_node.add_child(Node::new("expiry_height", Primitive::U32(expiry))); - } - if !zcash_psbt.sapling_fields.is_empty() { - psbt_node.add_child(Node::new( - "sapling_fields_len", - Primitive::U64(zcash_psbt.sapling_fields.len() as u64), - )); - } - - // Create ZcashTransactionParts from the PSBT's unsigned_tx - let parts = ZcashTransactionParts { - transaction: psbt.unsigned_tx.clone(), - is_overwintered: zcash_psbt.version_group_id.is_some(), - version_group_id: zcash_psbt.version_group_id, - expiry_height: zcash_psbt.expiry_height, - sapling_fields: zcash_psbt.sapling_fields.clone(), - }; - psbt_node.add_child(zcash_tx_to_node(&parts, network)); - - psbt_node.add_child(xpubs_to_node(&psbt.xpub)); - - if !psbt.proprietary.is_empty() { - let mut proprietary_node = - Node::new("proprietary", Primitive::U64(psbt.proprietary.len() as u64)); - proprietary_node.extend(proprietary_to_nodes(&psbt.proprietary)); - psbt_node.add_child(proprietary_node); - } - - psbt_node.add_child(Node::new("version", Primitive::U32(psbt.version))); - psbt_node.add_child(psbt_inputs_to_node(&psbt.inputs, network)); - psbt_node.add_child(psbt_outputs_to_node(&psbt.outputs)); - - psbt_node -} - -/// The minimum partial signatures a fixed-script (2-of-3 P2SH multisig) transparent input needs -/// before it can be finalized — see `ZcashBitGoPsbt::finalized_transparent_tx`'s `REQUIRED_SIGS`. -const V6_TRANSPARENT_REQUIRED_SIGS: usize = 2; - -/// Convert an Ironwood (v6) `ZcashBitGoPsbt` to a Node tree. -/// -/// Unlike the v4/Sapling-shaped path, the transparent skeleton in `unsigned_tx` is rendered -/// directly (a v6 PSBT is a plain PSBT, not an embedded overwintered transaction), and a -/// dedicated `ironwood` node surfaces the shielded output data that lives out-of-band in the -/// proprietary-map PCZT — invisible to `unsigned_tx.output` and so absent from the generic -/// transparent-output accounting above. This handles unsigned, half-signed, and fully-signed -/// (transparent-side) PSBTs uniformly: the shielded action data is read from the PCZT's -/// plaintext fields, which do not depend on transparent signing state. -fn ironwood_v6_psbt_to_node(zcash_psbt: &ZcashBitGoPsbt, network: Network) -> Node { - let psbt = &zcash_psbt.psbt; - let mut psbt_node = Node::new("psbt", Primitive::None); - - psbt_node.add_child(Node::new("is_ironwood_v6", Primitive::Boolean(true))); - if let Some(vgid) = zcash_psbt.version_group_id { - psbt_node.add_child(Node::new("version_group_id", Primitive::U32(vgid))); - } - if let Some(expiry) = zcash_psbt.expiry_height { - psbt_node.add_child(Node::new("expiry_height", Primitive::U32(expiry))); - } - - // The transparent skeleton: a v6 PSBT's `unsigned_tx` is a plain rust-bitcoin transaction, so - // the shared (non-Zcash-specific) renderer applies directly. - psbt_node.add_child(tx_to_node(&psbt.unsigned_tx, network)); - - psbt_node.add_child(ironwood_shielded_state_to_node(zcash_psbt)); - - psbt_node.add_child(xpubs_to_node(&psbt.xpub)); - - if !psbt.proprietary.is_empty() { - let mut proprietary_node = - Node::new("proprietary", Primitive::U64(psbt.proprietary.len() as u64)); - proprietary_node.extend(proprietary_to_nodes(&psbt.proprietary)); - psbt_node.add_child(proprietary_node); - } - - psbt_node.add_child(Node::new("version", Primitive::U32(psbt.version))); - psbt_node.add_child(psbt_inputs_to_node(&psbt.inputs, network)); - psbt_node.add_child(psbt_outputs_to_node(&psbt.outputs)); - - // Overall signing state of the transparent side, since a v6 PSBT is never "fully signed" on - // the shielded side until the external proof service and `combine_ironwood_proof` run (that - // step produces a broadcast-ready transaction, not another PSBT). - psbt_node.add_child(Node::new( - "transparent_signing_state", - Primitive::String(transparent_signing_state(&psbt.inputs).to_string()), - )); - - psbt_node -} - -/// "unsigned" (no input has any partial signature), "fully_signed" (every input has at least -/// [`V6_TRANSPARENT_REQUIRED_SIGS`] partial signatures), or "half_signed" (anything in between, -/// including a PSBT with no transparent inputs at all treated as unsigned). -fn transparent_signing_state(inputs: &[crate::bitcoin::psbt::Input]) -> &'static str { - if inputs.is_empty() || inputs.iter().all(|i| i.partial_sigs.is_empty()) { - return "unsigned"; - } - if inputs - .iter() - .all(|i| i.partial_sigs.len() >= V6_TRANSPARENT_REQUIRED_SIGS) - { - return "fully_signed"; - } - "half_signed" -} - -/// The `ironwood` node: every real shielded output's value/recipient, every real shielded spend's -/// value/nullifier (when the bundle spends notes), and the bundle's -/// action-data (commitments, ciphertexts, flags, value balance, anchor) read from the PCZT stored -/// in the proprietary map. Reports an error message inline rather than aborting the whole parse if -/// the PCZT is malformed or missing (e.g. a v6 PSBT that had `add_ironwood_output`/ -/// `add_ironwood_outputs` never called, or was already extracted via `combine_ironwood_proof`). -fn ironwood_shielded_state_to_node(zcash_psbt: &ZcashBitGoPsbt) -> Node { - let mut node = Node::new("ironwood", Primitive::None); - - match zcash_psbt.ironwood_shielded_outputs_info() { - Ok(outputs) if outputs.is_empty() => { - node.add_child(Node::new( - "shielded_outputs", - Primitive::String("none".to_string()), - )); - } - Ok(outputs) => { - let mut outputs_node = Node::new("shielded_outputs", Primitive::None); - for (action_index, value, recipient) in outputs { - let mut output_node = Node::new("shielded_output", Primitive::None); - output_node.add_child(Node::new( - "action_index", - Primitive::U64(action_index as u64), - )); - output_node.add_child(Node::new("value", Primitive::U64(value))); - output_node.add_child(Node::new( - "recipient", - Primitive::Buffer(recipient.to_vec()), - )); - outputs_node.add_child(output_node); - } - node.add_child(outputs_node); - } - Err(e) => { - node.add_child(Node::new("shielded_output_error", Primitive::String(e))); - } - } - - // Only rendered for bundles that spend real notes, so shielding-only output is unchanged. - match zcash_psbt.ironwood_shielded_spends_info() { - Ok(spends) if spends.is_empty() => {} - Ok(spends) => { - let mut spends_node = Node::new("shielded_spends", Primitive::None); - for spend in spends { - let mut spend_node = Node::new("shielded_spend", Primitive::None); - spend_node.add_child(Node::new( - "action_index", - Primitive::U64(spend.action_index as u64), - )); - spend_node.add_child(Node::new("value", Primitive::U64(spend.value))); - spend_node.add_child(Node::new( - "nullifier", - Primitive::Buffer(spend.nullifier.to_vec()), - )); - spends_node.add_child(spend_node); - } - node.add_child(spends_node); - } - Err(e) => { - node.add_child(Node::new("shielded_spend_error", Primitive::String(e))); - } - } - - match zcash_psbt.ironwood_action_data() { - Ok(bundle) => { - node.add_child(Node::new( - "action_count", - Primitive::U64(bundle.actions.len() as u64), - )); - node.add_child(Node::new("flags", Primitive::U8(bundle.flags))); - node.add_child(Node::new( - "value_balance", - Primitive::String(bundle.value_balance.to_string()), - )); - node.add_child(Node::new( - "anchor", - Primitive::Buffer(bundle.anchor.to_vec()), - )); - // `pczt_action_data` always returns these empty — see its doc comment — so their - // presence would only ever indicate a bug, not a later pipeline stage; not rendered. - } - Err(e) => { - node.add_child(Node::new("action_data_error", Primitive::String(e))); - } - } - - node -} - pub fn parse_psbt_bytes_with_network( bytes: &[u8], network: crate::networks::Network, @@ -763,7 +511,7 @@ pub fn parse_psbt_bytes_with_network( if matches!(network, NetEnum::Zcash | NetEnum::ZcashTestnet) { let zcash_psbt = ZcashBitGoPsbt::deserialize(bytes, network) .map_err(|e| format!("Zcash PSBT parse error: {}", e))?; - return Ok(zcash_psbt_to_node(&zcash_psbt, network)); + return Ok(super::zcash::zcash_psbt_to_node(&zcash_psbt, network)); } // Standard Bitcoin-compatible PSBT parsing @@ -782,7 +530,7 @@ pub fn parse_tx_bytes_with_network( if matches!(network, NetEnum::Zcash | NetEnum::ZcashTestnet) { let parts = decode_zcash_transaction_parts(bytes) .map_err(|e| format!("Zcash transaction parse error: {}", e))?; - return Ok(zcash_tx_to_node(&parts, network)); + return Ok(super::zcash::zcash_tx_to_node(&parts, network)); } // Standard Bitcoin-compatible transaction parsing @@ -790,171 +538,3 @@ pub fn parse_tx_bytes_with_network( .map(|tx| tx_to_node(&tx, network)) .map_err(|e| e.to_string()) } - -/// Parsing of v6 (Ironwood) shielding PSBTs at unsigned/half-signed/fully-signed transparent -/// signing states. Native-only (orchard + zebra), matching -/// `bitgo_psbt::zcash::tests::ironwood`. -#[cfg(all(test, not(target_arch = "wasm32")))] -mod ironwood_v6_tests { - use super::*; - use crate::bitcoin::bip32::DerivationPath; - use crate::bitcoin::hashes::{sha256, Hash}; - use crate::bitcoin::secp256k1::{Message, Secp256k1}; - use crate::bitcoin::{CompressedPublicKey, Network as BtcNetwork, PublicKey, Txid}; - use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::WalletInputOptions; - use crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt; - use crate::fixed_script_wallet::script_id::ScriptId; - use crate::fixed_script_wallet::test_utils::get_test_wallet_keys; - use crate::fixed_script_wallet::wallet_scripts::chain_index_path; - use crate::fixed_script_wallet::RootWalletKeys; - use crate::networks::Network as NetEnum; - use crate::zcash::NetworkUpgrade; - use orchard::keys::{FullViewingKey, Scope, SpendingKey}; - use orchard::tree::Anchor; - use rand::rngs::OsRng; - use std::str::FromStr; - - fn test_recipient() -> [u8; 43] { - let sk = Option::::from(SpendingKey::from_bytes([9u8; 32])).unwrap(); - FullViewingKey::from(&sk) - .address_at(0u32, Scope::External) - .to_raw_address_bytes() - } - - /// Builds an unsigned v6 shielding PSBT (one 2-of-3 P2SH transparent input, a transparent - /// change output, one shielded Ironwood output) and its bytes. - fn build_shield_psbt( - seed: &str, - ) -> ( - crate::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt, - [SecretKeyTriple; 1], - ) { - let wallet_keys = RootWalletKeys::new(get_test_wallet_keys(seed)); - let mut psbt = BitGoPsbt::new_zcash_v6_at_height( - NetEnum::ZcashTestnet, - &wallet_keys, - NetworkUpgrade::Nu6_3.testnet_activation_height(), - None, - None, - ) - .unwrap(); - psbt.add_wallet_input( - Txid::from_byte_array([0x55u8; 32]), - 0, - 200_000_000, - &wallet_keys, - ScriptId { chain: 0, index: 0 }, - WalletInputOptions::default(), - ) - .unwrap(); - psbt.add_wallet_output(0, 1, 99_900_000, &wallet_keys) - .unwrap(); - let BitGoPsbt::Zcash(mut z, _) = psbt else { - panic!("expected Zcash PSBT"); - }; - z.add_ironwood_output( - &test_recipient(), - 100_000_000, - None, - &Anchor::empty_tree().to_bytes(), - &[0u8; 512], - None, // unified_address - OsRng, - ) - .unwrap(); - - let prefix = DerivationPath::from_str("m/0/0").unwrap(); - let path = prefix.extend(chain_index_path(0, 0)); - let secp = Secp256k1::new(); - let mut keys = Vec::with_capacity(3); - for i in 0..3u8 { - let hash = sha256::Hash::hash(format!("{seed}.{i}").as_bytes()).to_byte_array(); - let master = - crate::bitcoin::bip32::Xpriv::new_master(BtcNetwork::Testnet, &hash).unwrap(); - keys.push(master.derive_priv(&secp, &path).unwrap().private_key); - } - (z, [keys.try_into().unwrap()]) - } - - type SecretKeyTriple = [crate::bitcoin::secp256k1::SecretKey; 3]; - - fn sign_input( - z: &mut crate::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt, - keys: &SecretKeyTriple, - which: &[usize], - ) { - let secp = Secp256k1::new(); - let sighash = z.v6_transparent_sighash(0).unwrap(); - let msg = Message::from_digest(sighash); - for &i in which { - let sk = keys[i]; - let secp_pk = crate::bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &sk); - let pubkey = PublicKey::from(CompressedPublicKey(secp_pk)); - let mut der = secp.sign_ecdsa(&msg, &sk).serialize_der().to_vec(); - der.push(0x01); - z.add_v6_transparent_signature(0, pubkey, &der).unwrap(); - } - } - - fn parse(z: &crate::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt) -> Node { - let bytes = z.serialize().unwrap(); - parse_psbt_bytes_with_network(&bytes, NetEnum::ZcashTestnet).unwrap() - } - - fn find<'a>(node: &'a Node, label: &str) -> Option<&'a Node> { - node.children.iter().find(|c| c.label == label) - } - - fn assert_shielded_output(node: &Node, expected_value: u64, expected_recipient: &[u8; 43]) { - let ironwood = find(node, "ironwood").expect("ironwood node present"); - let outputs = find(ironwood, "shielded_outputs").expect("shielded_outputs node present"); - let output = find(outputs, "shielded_output").expect("shielded_output node present"); - let value = find(output, "value").expect("value present"); - match &value.value { - Primitive::U64(v) => assert_eq!(*v, expected_value), - other => panic!("unexpected primitive: {other:?}"), - } - let recipient = find(output, "recipient").expect("recipient present"); - match &recipient.value { - Primitive::Buffer(b) => assert_eq!(b.as_slice(), expected_recipient.as_slice()), - other => panic!("unexpected primitive: {other:?}"), - } - } - - fn signing_state(node: &Node) -> String { - match &find(node, "transparent_signing_state").unwrap().value { - Primitive::String(s) => s.clone(), - other => panic!("unexpected primitive: {other:?}"), - } - } - - #[test] - fn parses_unsigned_v6_shielding_psbt() { - let (z, _keys) = build_shield_psbt("inspect_v6_unsigned"); - let node = parse(&z); - match &find(&node, "is_ironwood_v6").unwrap().value { - Primitive::Boolean(b) => assert!(*b), - other => panic!("unexpected primitive: {other:?}"), - } - assert_shielded_output(&node, 100_000_000, &test_recipient()); - assert_eq!(signing_state(&node), "unsigned"); - } - - #[test] - fn parses_half_signed_v6_shielding_psbt() { - let (mut z, keys) = build_shield_psbt("inspect_v6_half_signed"); - sign_input(&mut z, &keys[0], &[0]); - let node = parse(&z); - assert_shielded_output(&node, 100_000_000, &test_recipient()); - assert_eq!(signing_state(&node), "half_signed"); - } - - #[test] - fn parses_fully_signed_v6_shielding_psbt() { - let (mut z, keys) = build_shield_psbt("inspect_v6_fully_signed"); - sign_input(&mut z, &keys[0], &[0, 2]); - let node = parse(&z); - assert_shielded_output(&node, 100_000_000, &test_recipient()); - assert_eq!(signing_state(&node), "fully_signed"); - } -} diff --git a/packages/wasm-utxo/src/inspect/zcash.rs b/packages/wasm-utxo/src/inspect/zcash.rs new file mode 100644 index 00000000000..236484b0539 --- /dev/null +++ b/packages/wasm-utxo/src/inspect/zcash.rs @@ -0,0 +1,437 @@ +//! Node-tree rendering for Zcash PSBTs and transactions. +//! +//! Split out of `inspect/psbt.rs`; the coin-neutral dispatch in [`super::psbt`] routes +//! Zcash networks here. + +use super::node::{Node, Primitive}; +use super::psbt::{ + proprietary_to_nodes, psbt_inputs_to_node, psbt_outputs_to_node, tx_inputs_to_node, + tx_outputs_to_node, tx_to_node, xpubs_to_node, +}; +use crate::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt; +use crate::networks::Network; +use crate::zcash::transaction::ZcashTransactionParts; + +/// Convert a Zcash transaction (ZcashTransactionParts) to a Node tree +pub fn zcash_tx_to_node(parts: &ZcashTransactionParts, network: Network) -> Node { + let tx = &parts.transaction; + let mut tx_node = Node::new("tx", Primitive::None); + + // Zcash-specific fields first + if parts.is_overwintered { + tx_node.add_child(Node::new("is_overwintered", Primitive::Boolean(true))); + } + if let Some(vgid) = parts.version_group_id { + tx_node.add_child(Node::new("version_group_id", Primitive::U32(vgid))); + } + if let Some(expiry) = parts.expiry_height { + tx_node.add_child(Node::new("expiry_height", Primitive::U32(expiry))); + } + if !parts.sapling_fields.is_empty() { + tx_node.add_child(Node::new( + "sapling_fields", + Primitive::Buffer(parts.sapling_fields.clone()), + )); + } + + // Standard transaction fields (reuse helpers) + tx_node.add_child(Node::new("version", Primitive::I32(tx.version.0))); + tx_node.add_child(Node::new( + "lock_time", + Primitive::U32(tx.lock_time.to_consensus_u32()), + )); + tx_node.add_child(Node::new( + "txid", + Primitive::String(tx.compute_txid().to_string()), + )); + tx_node.add_child(Node::new( + "ntxid", + Primitive::String(tx.compute_ntxid().to_string()), + )); + tx_node.add_child(Node::new( + "wtxid", + Primitive::String(tx.compute_wtxid().to_string()), + )); + tx_node.add_child(tx_inputs_to_node(&tx.input)); + tx_node.add_child(tx_outputs_to_node(&tx.output, network)); + + tx_node +} + +/// Convert a ZcashBitGoPsbt to a Node tree +pub fn zcash_psbt_to_node(zcash_psbt: &ZcashBitGoPsbt, network: Network) -> Node { + if zcash_psbt.is_ironwood_v6() { + return ironwood_v6_psbt_to_node(zcash_psbt, network); + } + + let psbt = &zcash_psbt.psbt; + let mut psbt_node = Node::new("psbt", Primitive::None); + + // Zcash-specific fields at PSBT level + if let Some(vgid) = zcash_psbt.version_group_id { + psbt_node.add_child(Node::new("version_group_id", Primitive::U32(vgid))); + } + if let Some(expiry) = zcash_psbt.expiry_height { + psbt_node.add_child(Node::new("expiry_height", Primitive::U32(expiry))); + } + if !zcash_psbt.sapling_fields.is_empty() { + psbt_node.add_child(Node::new( + "sapling_fields_len", + Primitive::U64(zcash_psbt.sapling_fields.len() as u64), + )); + } + + // Create ZcashTransactionParts from the PSBT's unsigned_tx + let parts = ZcashTransactionParts { + transaction: psbt.unsigned_tx.clone(), + is_overwintered: zcash_psbt.version_group_id.is_some(), + version_group_id: zcash_psbt.version_group_id, + expiry_height: zcash_psbt.expiry_height, + sapling_fields: zcash_psbt.sapling_fields.clone(), + }; + psbt_node.add_child(zcash_tx_to_node(&parts, network)); + + psbt_node.add_child(xpubs_to_node(&psbt.xpub)); + + if !psbt.proprietary.is_empty() { + let mut proprietary_node = + Node::new("proprietary", Primitive::U64(psbt.proprietary.len() as u64)); + proprietary_node.extend(proprietary_to_nodes(&psbt.proprietary)); + psbt_node.add_child(proprietary_node); + } + + psbt_node.add_child(Node::new("version", Primitive::U32(psbt.version))); + psbt_node.add_child(psbt_inputs_to_node(&psbt.inputs, network)); + psbt_node.add_child(psbt_outputs_to_node(&psbt.outputs)); + + psbt_node +} + +/// The minimum partial signatures a fixed-script (2-of-3 P2SH multisig) transparent input needs +/// before it can be finalized — see `ZcashBitGoPsbt::finalized_transparent_tx`'s `REQUIRED_SIGS`. +const V6_TRANSPARENT_REQUIRED_SIGS: usize = 2; + +/// Convert an Ironwood (v6) `ZcashBitGoPsbt` to a Node tree. +/// +/// Unlike the v4/Sapling-shaped path, the transparent skeleton in `unsigned_tx` is rendered +/// directly (a v6 PSBT is a plain PSBT, not an embedded overwintered transaction), and a +/// dedicated `ironwood` node surfaces the shielded output data that lives out-of-band in the +/// proprietary-map PCZT — invisible to `unsigned_tx.output` and so absent from the generic +/// transparent-output accounting above. This handles unsigned, half-signed, and fully-signed +/// (transparent-side) PSBTs uniformly: the shielded action data is read from the PCZT's +/// plaintext fields, which do not depend on transparent signing state. +fn ironwood_v6_psbt_to_node(zcash_psbt: &ZcashBitGoPsbt, network: Network) -> Node { + let psbt = &zcash_psbt.psbt; + let mut psbt_node = Node::new("psbt", Primitive::None); + + psbt_node.add_child(Node::new("is_ironwood_v6", Primitive::Boolean(true))); + if let Some(vgid) = zcash_psbt.version_group_id { + psbt_node.add_child(Node::new("version_group_id", Primitive::U32(vgid))); + } + if let Some(expiry) = zcash_psbt.expiry_height { + psbt_node.add_child(Node::new("expiry_height", Primitive::U32(expiry))); + } + + // The transparent skeleton: a v6 PSBT's `unsigned_tx` is a plain rust-bitcoin transaction, so + // the shared (non-Zcash-specific) renderer applies directly. + psbt_node.add_child(tx_to_node(&psbt.unsigned_tx, network)); + + psbt_node.add_child(ironwood_shielded_state_to_node(zcash_psbt)); + + psbt_node.add_child(xpubs_to_node(&psbt.xpub)); + + if !psbt.proprietary.is_empty() { + let mut proprietary_node = + Node::new("proprietary", Primitive::U64(psbt.proprietary.len() as u64)); + proprietary_node.extend(proprietary_to_nodes(&psbt.proprietary)); + psbt_node.add_child(proprietary_node); + } + + psbt_node.add_child(Node::new("version", Primitive::U32(psbt.version))); + psbt_node.add_child(psbt_inputs_to_node(&psbt.inputs, network)); + psbt_node.add_child(psbt_outputs_to_node(&psbt.outputs)); + + // Overall signing state of the transparent side, since a v6 PSBT is never "fully signed" on + // the shielded side until the external proof service and `combine_ironwood_proof` run (that + // step produces a broadcast-ready transaction, not another PSBT). + psbt_node.add_child(Node::new( + "transparent_signing_state", + Primitive::String(transparent_signing_state(&psbt.inputs).to_string()), + )); + + psbt_node +} + +/// "unsigned" (no input has any partial signature), "fully_signed" (every input has at least +/// [`V6_TRANSPARENT_REQUIRED_SIGS`] partial signatures), or "half_signed" (anything in between, +/// including a PSBT with no transparent inputs at all treated as unsigned). +fn transparent_signing_state(inputs: &[crate::bitcoin::psbt::Input]) -> &'static str { + if inputs.is_empty() || inputs.iter().all(|i| i.partial_sigs.is_empty()) { + return "unsigned"; + } + if inputs + .iter() + .all(|i| i.partial_sigs.len() >= V6_TRANSPARENT_REQUIRED_SIGS) + { + return "fully_signed"; + } + "half_signed" +} + +/// The `ironwood` node: every real shielded output's value/recipient, every real shielded spend's +/// value/nullifier (when the bundle spends notes), and the bundle's +/// action-data (commitments, ciphertexts, flags, value balance, anchor) read from the PCZT stored +/// in the proprietary map. Reports an error message inline rather than aborting the whole parse if +/// the PCZT is malformed or missing (e.g. a v6 PSBT that had `add_ironwood_output`/ +/// `add_ironwood_outputs` never called, or was already extracted via `combine_ironwood_proof`). +fn ironwood_shielded_state_to_node(zcash_psbt: &ZcashBitGoPsbt) -> Node { + let mut node = Node::new("ironwood", Primitive::None); + + match zcash_psbt.ironwood_shielded_outputs_info() { + Ok(outputs) if outputs.is_empty() => { + node.add_child(Node::new( + "shielded_outputs", + Primitive::String("none".to_string()), + )); + } + Ok(outputs) => { + let mut outputs_node = Node::new("shielded_outputs", Primitive::None); + for (action_index, value, recipient) in outputs { + let mut output_node = Node::new("shielded_output", Primitive::None); + output_node.add_child(Node::new( + "action_index", + Primitive::U64(action_index as u64), + )); + output_node.add_child(Node::new("value", Primitive::U64(value))); + output_node.add_child(Node::new( + "recipient", + Primitive::Buffer(recipient.to_vec()), + )); + outputs_node.add_child(output_node); + } + node.add_child(outputs_node); + } + Err(e) => { + node.add_child(Node::new("shielded_output_error", Primitive::String(e))); + } + } + + // Only rendered for bundles that spend real notes, so shielding-only output is unchanged. + match zcash_psbt.ironwood_shielded_spends_info() { + Ok(spends) if spends.is_empty() => {} + Ok(spends) => { + let mut spends_node = Node::new("shielded_spends", Primitive::None); + for spend in spends { + let mut spend_node = Node::new("shielded_spend", Primitive::None); + spend_node.add_child(Node::new( + "action_index", + Primitive::U64(spend.action_index as u64), + )); + spend_node.add_child(Node::new("value", Primitive::U64(spend.value))); + spend_node.add_child(Node::new( + "nullifier", + Primitive::Buffer(spend.nullifier.to_vec()), + )); + spends_node.add_child(spend_node); + } + node.add_child(spends_node); + } + Err(e) => { + node.add_child(Node::new("shielded_spend_error", Primitive::String(e))); + } + } + + match zcash_psbt.ironwood_action_data() { + Ok(bundle) => { + node.add_child(Node::new( + "action_count", + Primitive::U64(bundle.actions.len() as u64), + )); + node.add_child(Node::new("flags", Primitive::U8(bundle.flags))); + node.add_child(Node::new( + "value_balance", + Primitive::String(bundle.value_balance.to_string()), + )); + node.add_child(Node::new( + "anchor", + Primitive::Buffer(bundle.anchor.to_vec()), + )); + // `pczt_action_data` always returns these empty — see its doc comment — so their + // presence would only ever indicate a bug, not a later pipeline stage; not rendered. + } + Err(e) => { + node.add_child(Node::new("action_data_error", Primitive::String(e))); + } + } + + node +} + +/// Parsing of v6 (Ironwood) shielding PSBTs at unsigned/half-signed/fully-signed transparent +/// signing states. Native-only (orchard + zebra), matching +/// `bitgo_psbt::zcash::tests::ironwood`. +#[cfg(all(test, not(target_arch = "wasm32")))] +mod ironwood_v6_tests { + use super::*; + use crate::bitcoin::bip32::DerivationPath; + use crate::bitcoin::hashes::{sha256, Hash}; + use crate::bitcoin::secp256k1::{Message, Secp256k1}; + use crate::bitcoin::{CompressedPublicKey, Network as BtcNetwork, PublicKey, Txid}; + use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::WalletInputOptions; + use crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt; + use crate::fixed_script_wallet::script_id::ScriptId; + use crate::fixed_script_wallet::test_utils::get_test_wallet_keys; + use crate::fixed_script_wallet::wallet_scripts::chain_index_path; + use crate::fixed_script_wallet::RootWalletKeys; + use crate::inspect::psbt::parse_psbt_bytes_with_network; + use crate::networks::Network as NetEnum; + use crate::zcash::NetworkUpgrade; + use orchard::keys::{FullViewingKey, Scope, SpendingKey}; + use orchard::tree::Anchor; + use rand::rngs::OsRng; + use std::str::FromStr; + + fn test_recipient() -> [u8; 43] { + let sk = Option::::from(SpendingKey::from_bytes([9u8; 32])).unwrap(); + FullViewingKey::from(&sk) + .address_at(0u32, Scope::External) + .to_raw_address_bytes() + } + + /// Builds an unsigned v6 shielding PSBT (one 2-of-3 P2SH transparent input, a transparent + /// change output, one shielded Ironwood output) and its bytes. + fn build_shield_psbt( + seed: &str, + ) -> ( + crate::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt, + [SecretKeyTriple; 1], + ) { + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys(seed)); + let mut psbt = BitGoPsbt::new_zcash_v6_at_height( + NetEnum::ZcashTestnet, + &wallet_keys, + NetworkUpgrade::Nu6_3.testnet_activation_height(), + None, + None, + ) + .unwrap(); + psbt.add_wallet_input( + Txid::from_byte_array([0x55u8; 32]), + 0, + 200_000_000, + &wallet_keys, + ScriptId { chain: 0, index: 0 }, + WalletInputOptions::default(), + ) + .unwrap(); + psbt.add_wallet_output(0, 1, 99_900_000, &wallet_keys) + .unwrap(); + let BitGoPsbt::Zcash(mut z, _) = psbt else { + panic!("expected Zcash PSBT"); + }; + z.add_ironwood_output( + &test_recipient(), + 100_000_000, + None, + &Anchor::empty_tree().to_bytes(), + &[0u8; 512], + None, // unified_address + OsRng, + ) + .unwrap(); + + let prefix = DerivationPath::from_str("m/0/0").unwrap(); + let path = prefix.extend(chain_index_path(0, 0)); + let secp = Secp256k1::new(); + let mut keys = Vec::with_capacity(3); + for i in 0..3u8 { + let hash = sha256::Hash::hash(format!("{seed}.{i}").as_bytes()).to_byte_array(); + let master = + crate::bitcoin::bip32::Xpriv::new_master(BtcNetwork::Testnet, &hash).unwrap(); + keys.push(master.derive_priv(&secp, &path).unwrap().private_key); + } + (z, [keys.try_into().unwrap()]) + } + + type SecretKeyTriple = [crate::bitcoin::secp256k1::SecretKey; 3]; + + fn sign_input( + z: &mut crate::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt, + keys: &SecretKeyTriple, + which: &[usize], + ) { + let secp = Secp256k1::new(); + let sighash = z.v6_transparent_sighash(0).unwrap(); + let msg = Message::from_digest(sighash); + for &i in which { + let sk = keys[i]; + let secp_pk = crate::bitcoin::secp256k1::PublicKey::from_secret_key(&secp, &sk); + let pubkey = PublicKey::from(CompressedPublicKey(secp_pk)); + let mut der = secp.sign_ecdsa(&msg, &sk).serialize_der().to_vec(); + der.push(0x01); + z.add_v6_transparent_signature(0, pubkey, &der).unwrap(); + } + } + + fn parse(z: &crate::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt) -> Node { + let bytes = z.serialize().unwrap(); + parse_psbt_bytes_with_network(&bytes, NetEnum::ZcashTestnet).unwrap() + } + + fn find<'a>(node: &'a Node, label: &str) -> Option<&'a Node> { + node.children.iter().find(|c| c.label == label) + } + + fn assert_shielded_output(node: &Node, expected_value: u64, expected_recipient: &[u8; 43]) { + let ironwood = find(node, "ironwood").expect("ironwood node present"); + let outputs = find(ironwood, "shielded_outputs").expect("shielded_outputs node present"); + let output = find(outputs, "shielded_output").expect("shielded_output node present"); + let value = find(output, "value").expect("value present"); + match &value.value { + Primitive::U64(v) => assert_eq!(*v, expected_value), + other => panic!("unexpected primitive: {other:?}"), + } + let recipient = find(output, "recipient").expect("recipient present"); + match &recipient.value { + Primitive::Buffer(b) => assert_eq!(b.as_slice(), expected_recipient.as_slice()), + other => panic!("unexpected primitive: {other:?}"), + } + } + + fn signing_state(node: &Node) -> String { + match &find(node, "transparent_signing_state").unwrap().value { + Primitive::String(s) => s.clone(), + other => panic!("unexpected primitive: {other:?}"), + } + } + + #[test] + fn parses_unsigned_v6_shielding_psbt() { + let (z, _keys) = build_shield_psbt("inspect_v6_unsigned"); + let node = parse(&z); + match &find(&node, "is_ironwood_v6").unwrap().value { + Primitive::Boolean(b) => assert!(*b), + other => panic!("unexpected primitive: {other:?}"), + } + assert_shielded_output(&node, 100_000_000, &test_recipient()); + assert_eq!(signing_state(&node), "unsigned"); + } + + #[test] + fn parses_half_signed_v6_shielding_psbt() { + let (mut z, keys) = build_shield_psbt("inspect_v6_half_signed"); + sign_input(&mut z, &keys[0], &[0]); + let node = parse(&z); + assert_shielded_output(&node, 100_000_000, &test_recipient()); + assert_eq!(signing_state(&node), "half_signed"); + } + + #[test] + fn parses_fully_signed_v6_shielding_psbt() { + let (mut z, keys) = build_shield_psbt("inspect_v6_fully_signed"); + sign_input(&mut z, &keys[0], &[0, 2]); + let node = parse(&z); + assert_shielded_output(&node, 100_000_000, &test_recipient()); + assert_eq!(signing_state(&node), "fully_signed"); + } +} 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 50f373cbc19..05d6ffebece 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -1,4 +1,5 @@ mod dimensions; +mod zcash_psbt; mod zip317_dimensions; pub use dimensions::WasmDimensions; @@ -10,7 +11,6 @@ use wasm_bindgen::JsValue; use crate::address::networks::AddressFormat; use crate::error::WasmUtxoError; -use crate::fixed_script_wallet::bitgo_psbt::zcash_psbt::IronwoodOutputRequest; use crate::fixed_script_wallet::bitgo_psbt::ExtractFeePolicy; use crate::fixed_script_wallet::wallet_scripts::{chain_index_path, OutputScriptType}; use crate::fixed_script_wallet::{Chain, Scope, WalletScripts}; @@ -22,10 +22,6 @@ use crate::wasm::replay_protection::WasmReplayProtection; use crate::wasm::try_from_js_value::TryFromJsValue; use crate::wasm::try_into_js_value::TryIntoJsValue; use crate::wasm::wallet_keys::WasmRootWalletKeys; -use crate::zcash::ironwood_build::{ - AnchorBytes, MemoBytes, OrchardAddressBytes, OvkBytes, ANCHOR_SIZE, MEMO_SIZE, - ORCHARD_ADDRESS_SIZE, OVK_SIZE, -}; /// Parse a network from a string that can be either a utxolib name or a coin name fn parse_network(network_str: &str) -> Result { @@ -313,30 +309,6 @@ pub struct BitGoPsbt { pub(crate) first_rounds: HashMap<(usize, String), musig2::FirstRound>, } -impl BitGoPsbt { - /// Borrow the inner `ZcashBitGoPsbt`, erroring if this is not a Zcash PSBT. - fn zcash( - &self, - ) -> Result<&crate::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt, WasmUtxoError> { - use crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt as InnerBitGoPsbt; - match &self.psbt { - InnerBitGoPsbt::Zcash(z, _) => Ok(z), - _ => Err(WasmUtxoError::new("not a Zcash PSBT")), - } - } - - /// Mutably borrow the inner `ZcashBitGoPsbt`, erroring if this is not a Zcash PSBT. - fn zcash_mut( - &mut self, - ) -> Result<&mut crate::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt, WasmUtxoError> { - use crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt as InnerBitGoPsbt; - match &mut self.psbt { - InnerBitGoPsbt::Zcash(z, _) => Ok(z), - _ => Err(WasmUtxoError::new("not a Zcash PSBT")), - } - } -} - #[wasm_bindgen] impl BitGoPsbt { /// Deserialize a PSBT from bytes with network-specific logic @@ -382,386 +354,6 @@ impl BitGoPsbt { }) } - /// Create an empty Zcash PSBT with the required consensus branch ID - /// - /// This method is specifically for Zcash networks which require additional - /// parameters for sighash computation. - /// - /// # Arguments - /// * `network` - Network name (must be "zcash" or "zcashTest") - /// * `wallet_keys` - The wallet's root keys (used to set global xpubs) - /// * `consensus_branch_id` - Zcash consensus branch ID (e.g., 0xC2D6D0B4 for NU5) - /// * `version` - Optional transaction version (default: 4 for Zcash Sapling+) - /// * `lock_time` - Optional lock time (default: 0) - /// * `version_group_id` - Optional version group ID (defaults to Sapling: 0x892F2085) - /// * `expiry_height` - Optional expiry height - pub fn create_empty_zcash( - network: &str, - wallet_keys: &WasmRootWalletKeys, - consensus_branch_id: u32, - version: Option, - lock_time: Option, - version_group_id: Option, - expiry_height: Option, - ) -> Result { - let network = parse_network(network)?; - let wallet_keys = wallet_keys.inner(); - - let psbt = crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::new_zcash( - network, - wallet_keys, - consensus_branch_id, - version, - lock_time, - version_group_id, - expiry_height, - ); - - Ok(BitGoPsbt { - psbt, - first_rounds: HashMap::new(), - }) - } - - /// Create an empty Zcash PSBT with consensus branch ID determined from block height - /// - /// This method automatically determines the correct consensus branch ID based on - /// the network and block height using the network upgrade activation heights. - /// - /// # Arguments - /// * `network` - Network name (must be "zcash" or "zcashTest") - /// * `wallet_keys` - The wallet's root keys (used to set global xpubs) - /// * `block_height` - Block height to determine consensus rules - /// * `version` - Optional transaction version (default: 4 for Zcash Sapling+) - /// * `lock_time` - Optional lock time (default: 0) - /// * `version_group_id` - Optional version group ID (defaults to Sapling: 0x892F2085) - /// * `expiry_height` - Optional expiry height - /// - /// # Errors - /// Returns error if block height is before Overwinter activation, or if the height is - /// at or after NU7 activation and the build would be a version 4 transaction — version 4 - /// is invalid under NU7 per ZIP-2003; use `create_empty_zcash_v6_at_height` instead - #[allow(clippy::too_many_arguments)] - pub fn create_empty_zcash_at_height( - network: &str, - wallet_keys: &WasmRootWalletKeys, - block_height: u32, - version: Option, - lock_time: Option, - version_group_id: Option, - expiry_height: Option, - ) -> Result { - let network = parse_network(network)?; - let wallet_keys = wallet_keys.inner(); - - let psbt = crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::new_zcash_at_height( - network, - wallet_keys, - block_height, - version, - lock_time, - version_group_id, - expiry_height, - ) - .map_err(|e| WasmUtxoError::new(&e))?; - - Ok(BitGoPsbt { - psbt, - first_rounds: HashMap::new(), - }) - } - - // ---- Zcash v6 (Ironwood / NU6.3) shielding ---- - - /// Create an empty Zcash **v6 (Ironwood)** shielding PSBT with an explicit consensus branch id. - /// - /// The version group id is fixed by the v6 format, but the consensus branch id tracks the active - /// network upgrade and stays caller-supplied — the v6 analogue of `create_empty_zcash`. Prefer - /// `create_empty_zcash_v6_at_height`, which derives it from a block height and rejects heights - /// before NU6.3 activation; this is the escape hatch for callers that already know the value. - pub fn create_empty_zcash_v6( - network: &str, - wallet_keys: &WasmRootWalletKeys, - consensus_branch_id: u32, - lock_time: Option, - expiry_height: Option, - ) -> Result { - let network = parse_network(network)?; - let wallet_keys = wallet_keys.inner(); - let psbt = crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::new_zcash_v6( - network, - wallet_keys, - consensus_branch_id, - lock_time, - expiry_height, - ); - Ok(BitGoPsbt { - psbt, - first_rounds: HashMap::new(), - }) - } - - /// Create an empty Zcash **v6 (Ironwood)** shielding PSBT, with the consensus branch id - /// resolved from `block_height`. Transparent inputs/outputs are added with the usual - /// `add_wallet_input` / `add_wallet_output`; the shielded output and the v6 sign/combine - /// steps use the dedicated `add_ironwood_output` / `ironwood_v6_*` / `combine_ironwood_proof` - /// methods. - pub fn create_empty_zcash_v6_at_height( - network: &str, - wallet_keys: &WasmRootWalletKeys, - block_height: u32, - lock_time: Option, - expiry_height: Option, - ) -> Result { - let network = parse_network(network)?; - let wallet_keys = wallet_keys.inner(); - let psbt = crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::new_zcash_v6_at_height( - network, - wallet_keys, - block_height, - lock_time, - expiry_height, - ) - .map_err(|e| WasmUtxoError::new(&e))?; - Ok(BitGoPsbt { - psbt, - first_rounds: HashMap::new(), - }) - } - - /// Constructor: add the shielded Ironwood output as an orchard PCZT stored in the PSBT. - /// - /// * `recipient` - raw Orchard/Ironwood address ([`ORCHARD_ADDRESS_SIZE`] bytes) - /// * `amount` - note value in zatoshi - /// * `ovk` - optional outgoing viewing key ([`OVK_SIZE`] bytes; `None` for a keyless build). - /// Has no effect in the normal flow: `sign_ironwood_v6`'s first round re-encrypts every - /// action's `out_ciphertext` under the wallet `ovk`, replacing whatever this produced - /// * `anchor` - Ironwood note-commitment-tree root ([`ANCHOR_SIZE`] bytes) - /// * `memo` - ZIP-302 memo field ([`MEMO_SIZE`] bytes) - /// * `unified_address` - optional full Unified Address the output was addressed to; if given, - /// its Orchard receiver must match `recipient`, and it is stored verbatim so output parsing - /// can later return it in full (transparent/Sapling receivers included) instead of - /// reconstructing a single-receiver UA from `recipient` alone - /// - /// [`ORCHARD_ADDRESS_SIZE`]: crate::zcash::ironwood_build::ORCHARD_ADDRESS_SIZE - /// [`OVK_SIZE`]: crate::zcash::ironwood_build::OVK_SIZE - /// [`ANCHOR_SIZE`]: crate::zcash::ironwood_build::ANCHOR_SIZE - /// [`MEMO_SIZE`]: crate::zcash::ironwood_build::MEMO_SIZE - pub fn add_ironwood_output( - &mut self, - recipient: &[u8], - amount: u64, - ovk: Option>, - anchor: &[u8], - memo: &[u8], - unified_address: Option, - ) -> Result<(), WasmUtxoError> { - /// Length-check a boundary byte string, naming the field and its expected size in the error. - fn fixed(bytes: &[u8], field: &str) -> Result<[u8; N], WasmUtxoError> { - bytes.try_into().map_err(|_| { - WasmUtxoError::new(&format!("{field} must be {N} bytes, got {}", bytes.len())) - }) - } - - let recipient: OrchardAddressBytes = fixed::(recipient, "recipient")?; - let anchor: AnchorBytes = fixed::(anchor, "anchor")?; - let memo: MemoBytes = fixed::(memo, "memo")?; - let ovk: Option = ovk.map(|v| fixed::(&v, "ovk")).transpose()?; - self.zcash_mut()? - .add_ironwood_output( - &recipient, - amount, - ovk, - &anchor, - &memo, - unified_address.as_deref(), - rand::rngs::OsRng, - ) - .map(|_action_index| ()) - .map_err(|e| WasmUtxoError::new(&e)) - } - - /// Constructor: add one or more shielded Ironwood outputs as a single orchard PCZT stored in - /// the PSBT — the multi-recipient counterpart to [`Self::add_ironwood_output`]. - /// - /// `outputs` is an array of `{ recipient: Uint8Array, amount: bigint, memo: Uint8Array, - /// ovk?: Uint8Array, unifiedAddress?: string }`, one entry per recipient — see - /// [`Self::add_ironwood_output`] for the meaning of each field. `anchor` is shared by every - /// output. - /// - /// Returns the action index assigned to each output, in the same order as `outputs` — the - /// orchard builder pads/reorders actions, so a caller correlating per-action data (a stored - /// Unified Address, an explicit `set_ironwood_out_ciphertext` call) must use these indices, not - /// the position in `outputs` — though note `sign_ironwood_v6`'s first round already patches - /// every action's `out_ciphertext` with the wallet `ovk` on its own. - pub fn add_ironwood_outputs( - &mut self, - outputs: JsValue, - anchor: &[u8], - ) -> Result, WasmUtxoError> { - let anchor: AnchorBytes = anchor.try_into().map_err(|_| { - WasmUtxoError::new(&format!( - "anchor must be {ANCHOR_SIZE} bytes, got {}", - anchor.len() - )) - })?; - - let arr = js_sys::Array::from(&outputs); - let requests = arr - .iter() - .map(|item| IronwoodOutputRequest::try_from_js_value(&item)) - .collect::, _>>()?; - - self.zcash_mut()? - .add_ironwood_outputs(&requests, &anchor, rand::rngs::OsRng) - .map(|indices| indices.into_iter().map(|i| i as u32).collect()) - .map_err(|e| WasmUtxoError::new(&e)) - } - - /// Client-managed `ovk`: re-encrypt the Ironwood output's `out_ciphertext` under this wallet's - /// `ovk`, derived as the ECDH agreement of `root_wallet_keys.bitgo_key()` and `user_xpriv` — two - /// root keys, so the result is transaction-independent and the server can re-derive the identical - /// `ovk` from its own privkey plus the user's pubkey in order to validate `out_ciphertext`. The - /// server never sees `user_xpriv` or the `ovk` itself. - /// - /// `user_xpriv` must be the wallet's user root key; any other key is rejected, since an `ovk` - /// derived from it is one neither the user nor the server can reproduce. - /// - /// Must be called before signing: `out_ciphertext` is ZIP-244 sighash-committed, so calling this - /// after any transparent signature has been collected for the PSBT is rejected (it would - /// silently invalidate that signature). - pub fn set_ironwood_out_ciphertext( - &mut self, - action_index: usize, - user_xpriv: &WasmBIP32, - root_wallet_keys: &WasmRootWalletKeys, - ) -> Result<(), WasmUtxoError> { - let user_xpriv = user_xpriv.to_xpriv()?; - let secp = miniscript::bitcoin::secp256k1::Secp256k1::new(); - self.zcash_mut()? - .set_ironwood_out_ciphertext_for_user( - action_index, - &user_xpriv, - root_wallet_keys.inner(), - &secp, - ) - .map_err(|e| WasmUtxoError::new(&e)) - } - - /// The canonical (display-order) ZIP-244 v6 txid as a lowercase hex string, matching the - /// `getId()` convention used elsewhere (see [`crate::wasm::zcash::ZcashV6Transaction::get_id`]). - pub fn ironwood_v6_txid(&self) -> Result { - use miniscript::bitcoin::hashes::Hash; - let txid = self - .zcash()? - .v6_txid() - .map_err(|e| WasmUtxoError::new(&e))?; - Ok(miniscript::bitcoin::Txid::from_byte_array(txid).to_string()) - } - - /// The ZIP-244 per-input transparent sighash (32 bytes) that the key controlling transparent - /// input `index` must sign. - pub fn ironwood_v6_transparent_sighash(&self, index: usize) -> Result, WasmUtxoError> { - self.zcash()? - .v6_transparent_sighash(index) - .map(|h| h.to_vec()) - .map_err(|e| WasmUtxoError::new(&e)) - } - - /// Ingest a transparent-input signature (`sig`: DER ECDSA with the trailing SIGHASH_ALL byte) - /// after verifying it against `ironwood_v6_transparent_sighash(index)`. - pub fn add_ironwood_v6_signature( - &mut self, - index: usize, - pubkey: &[u8], - sig: &[u8], - ) -> Result<(), WasmUtxoError> { - let pk = miniscript::bitcoin::PublicKey::from_slice(pubkey) - .map_err(|e| WasmUtxoError::new(&format!("invalid pubkey: {e}")))?; - self.zcash_mut()? - .add_v6_transparent_signature(index, pk, sig) - .map_err(|e| WasmUtxoError::new(&e)) - } - - /// Sign every transparent input `xpriv` resolves a key for, over the ZIP-244 transparent - /// sighash — the v6 (Ironwood) counterpart to `sign_all_wallet_inputs`, which rejects v6 PSBTs - /// (it only knows the ZIP-243 digest). - /// - /// If no transparent signature has been collected yet, this is the first signing round and - /// `xpriv` must be the wallet's user root key: it is used with `root_wallet_keys.bitgo_key()` to - /// derive this wallet's `ovk` and finalize every action's `out_ciphertext` (multi-recipient - /// builds included) before computing any sighash (it is - /// sighash-committed). Any other key signing first is rejected, rather than deriving an `ovk` - /// nobody can reproduce. Once a signature exists, the step is skipped and any key may sign — so - /// callers pass `root_wallet_keys` unconditionally on every signing round without needing to know - /// which key is signing; it is mandatory precisely so the step can never be skipped by omission. - /// - /// Returns the indices of the transparent inputs that were signed. - pub fn sign_ironwood_v6( - &mut self, - xpriv: &WasmBIP32, - root_wallet_keys: &WasmRootWalletKeys, - ) -> Result, WasmUtxoError> { - let xpriv = xpriv.to_xpriv()?; - let secp = miniscript::bitcoin::secp256k1::Secp256k1::new(); - self.zcash_mut()? - .sign_ironwood_v6(&xpriv, root_wallet_keys.inner(), &secp) - .map_err(|e| WasmUtxoError::new(&e)) - } - - /// Transaction Extractor: given the external prover's `proof` bytes, finalize the transparent - /// inputs, apply the shielded binding signature, and return the broadcast-ready v6 transaction - /// bytes. Requires the transparent inputs to be signed (via `add_ironwood_v6_signature`). - /// - /// Terminal: on success the stored PCZT is dropped, so a second call — or any other Ironwood - /// operation, including after a `serialize`/`from_bytes` round-trip — fails rather than silently - /// re-running against already-extracted state. Build a fresh PSBT instead. On failure nothing is - /// dropped, so a call that errors (bad proof, unsigned input) leaves the PSBT retryable. - pub fn combine_ironwood_proof(&mut self, proof: &[u8]) -> Result, WasmUtxoError> { - // The inner `combine_ironwood_proof` takes `self` by value (it is a consuming Extractor - // step), which does not translate across the wasm boundary — so clone, and mark this - // instance spent only once the clone has actually produced a transaction. - let tx = self - .zcash()? - .clone() - .combine_ironwood_proof(proof.to_vec(), rand::rngs::OsRng) - .map_err(|e| WasmUtxoError::new(&e))?; - self.zcash_mut()?.mark_ironwood_extracted(); - Ok(tx) - } - - /// Transaction Extractor for a transparent-only v6 (Ironwood) PSBT: no shielded output was - /// ever added, so there is no PCZT and nothing to combine an external proof into. Returns - /// broadcast-ready v6 transaction bytes with an empty Ironwood bundle slot. - /// - /// Not terminal (unlike `combine_ironwood_proof`): there is no stored PCZT to drop, so - /// calling this more than once is safe. - /// - /// Errors if a shielded output was ever added to this PSBT, or if any transparent input is - /// missing a required signature. See - /// [`crate::fixed_script_wallet::bitgo_psbt::zcash_psbt::ZcashBitGoPsbt::extract_transparent_only_v6_tx`]. - pub fn extract_transparent_only_v6_tx(&self) -> Result, WasmUtxoError> { - self.zcash()? - .extract_transparent_only_v6_tx() - .map_err(|e| WasmUtxoError::new(&e)) - } - - /// The raw serialized orchard PCZT (Partially Created Zcash Transaction) bundle stored in - /// this v6 (Ironwood) PSBT's proprietary key-value map, or `undefined` if none is present. - /// - /// A PCZT is the shielded-bundle counterpart to a PSBT: it accumulates the orchard action - /// (spend + output), the ZIP-244-committed `out_ciphertext`, and — once - /// [`Self::combine_ironwood_proof`] has run — the Halo2 `zkproof` and binding signature, - /// bridging this PSBT to the external proof service and back (see - /// [`crate::zcash::ironwood_pczt`] / [`crate::zcash::ironwood_build`]). - /// - /// Present only after [`Self::add_ironwood_output`] has been called on a v6 PSBT; `undefined` - /// beforehand, and `undefined` again after [`Self::combine_ironwood_proof`] succeeds, since - /// that call drops the stored PCZT to make extraction terminal (see its doc comment). - pub fn ironwood_pczt_bytes(&self) -> Option> { - self.zcash().ok().and_then(|z| z.raw_ironwood_pczt_bytes()) - } - /// Convert a half-signed legacy transaction to a psbt-lite. /// /// # Arguments @@ -924,89 +516,6 @@ impl BitGoPsbt { }) } - /// Convert a network-format Zcash transaction (0, 1, or 2 sigs) to a PSBT (with block height). - /// - /// Accepts unsigned, half-signed, and fully-signed Zcash transactions. - /// - /// # Arguments - /// * `tx` - The decoded Zcash transaction - /// * `network` - Network name ("zec" or "tzec") - /// * `wallet_keys` - The wallet's root keys - /// * `unspents` - Array of `{ chain: number, index: number, value: bigint }` for each input - /// * `block_height` - Block height to determine consensus branch ID - #[wasm_bindgen] - pub fn from_network_format_zcash_with_block_height( - tx: &crate::wasm::transaction::WasmZcashTransaction, - network: &str, - wallet_keys: &WasmRootWalletKeys, - unspents: JsValue, - block_height: u32, - ) -> Result { - let network_parsed = parse_network(network)?; - let is_mainnet = matches!(network_parsed, crate::networks::Network::Zcash); - - let consensus_branch_id = crate::zcash::branch_id_for_height(block_height, is_mainnet) - .ok_or_else(|| { - WasmUtxoError::new(&format!( - "Block height {} is before Overwinter activation on {}", - block_height, - if is_mainnet { "mainnet" } else { "testnet" } - )) - })?; - - Self::from_network_format_zcash_with_branch_id( - tx, - network, - wallet_keys, - unspents, - consensus_branch_id, - ) - } - - /// Convert a network-format Zcash transaction (0, 1, or 2 sigs) to a PSBT (with consensus branch ID). - /// - /// Accepts unsigned, half-signed, and fully-signed Zcash transactions. - /// - /// # Arguments - /// * `tx` - The decoded Zcash transaction - /// * `network` - Network name ("zec" or "tzec") - /// * `wallet_keys` - The wallet's root keys - /// * `unspents` - Array of `{ chain: number, index: number, value: bigint }` for each input - /// * `consensus_branch_id` - Zcash consensus branch ID - #[wasm_bindgen] - pub fn from_network_format_zcash_with_branch_id( - tx: &crate::wasm::transaction::WasmZcashTransaction, - network: &str, - wallet_keys: &WasmRootWalletKeys, - unspents: JsValue, - consensus_branch_id: u32, - ) -> Result { - use crate::fixed_script_wallet::bitgo_psbt::HydrationUnspentInput; - - let network = parse_network(network)?; - let wallet_keys = wallet_keys.inner(); - - let arr = js_sys::Array::from(&unspents); - let parsed_unspents = arr - .iter() - .map(|item| HydrationUnspentInput::try_from_js_value(&item)) - .collect::, _>>()?; - - let zcash = crate::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt::from_network_format( - &tx.parts, - network, - wallet_keys, - &parsed_unspents, - consensus_branch_id, - ) - .map_err(|e| WasmUtxoError::new(&e))?; - - Ok(BitGoPsbt { - psbt: crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::Zcash(zcash, network), - first_rounds: HashMap::new(), - }) - } - /// Add an input to the PSBT /// /// # Arguments @@ -1098,37 +607,6 @@ impl BitGoPsbt { Ok(self.psbt.add_output_with_address(address, value)?) } - /// Zcash-only: add a plain transparent output. Just `script`/`value` - /// (`unified_address: undefined`) is the legacy `add_output` behavior, unchanged. - /// - /// If `unified_address` is given, this PSBT must be a legacy v4 Zcash PSBT — a v6 (Ironwood) - /// PSBT's shielded side already has its own UA path (`add_ironwood_output`). The UA must be a - /// Unified Address whose transparent receiver is exactly `script`; mismatches are rejected - /// rather than silently stored. It is then kept verbatim, keyed by this output's index, so - /// `transparent_output_unified_address` can later return the original UA string rather than - /// just the bare scriptPubkey. - pub fn add_transparent_output( - &mut self, - script: &[u8], - value: u64, - unified_address: Option, - ) -> Result { - use miniscript::bitcoin::ScriptBuf; - let script = ScriptBuf::from_bytes(script.to_vec()); - self.zcash_mut()? - .add_transparent_output(script, value, unified_address.as_deref()) - .map_err(|e| WasmUtxoError::new(&e)) - } - - /// Zcash-only: the Unified Address stored by [`Self::add_transparent_output`] for output - /// `index`, if the caller supplied one — `undefined` otherwise (including for a non-Zcash - /// PSBT). - pub fn transparent_output_unified_address(&self, index: usize) -> Option { - self.zcash() - .ok() - .and_then(|z| z.transparent_output_unified_address(index)) - } - #[allow(clippy::too_many_arguments)] pub fn add_wallet_input_at_index( &mut self, @@ -1347,36 +825,6 @@ impl BitGoPsbt { } } - /// 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; - match &self.psbt { - InnerBitGoPsbt::Zcash(zcash_psbt, _) => zcash_psbt.version_group_id, - InnerBitGoPsbt::BitcoinLike(_, _) => None, - InnerBitGoPsbt::Dash(_, _) => None, - } - } - - /// Get the Zcash expiry height (returns None for non-Zcash PSBTs) - pub fn expiry_height(&self) -> Option { - use crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt as InnerBitGoPsbt; - match &self.psbt { - InnerBitGoPsbt::Zcash(zcash_psbt, _) => zcash_psbt.expiry_height, - InnerBitGoPsbt::BitcoinLike(_, _) => None, - InnerBitGoPsbt::Dash(_, _) => None, - } - } - - /// Get the Zcash consensus branch ID from the PSBT proprietary map (returns None for non-Zcash PSBTs) - pub fn consensus_branch_id(&self) -> Option { - use crate::fixed_script_wallet::bitgo_psbt::{ - zcash::propkv::get_zec_consensus_branch_id, BitGoPsbt as InnerBitGoPsbt, - }; - match &self.psbt { - InnerBitGoPsbt::Zcash(z, _) => get_zec_consensus_branch_id(&z.psbt), - _ => None, - } - } pub fn get_outputs_with_address(&self) -> Result { crate::wasm::psbt::get_outputs_with_address_from_psbt(self.psbt.psbt(), self.psbt.network()) } @@ -1525,40 +973,6 @@ impl BitGoPsbt { .map_err(|e| WasmUtxoError::new(&format!("Failed to verify signature: {}", e))) } - /// Verify if a valid signature exists for an extended public key at the specified input - /// index, computed over the ZIP-244 v6 (Ironwood) transparent sighash — the v6 (Ironwood) - /// counterpart to `verify_signature_with_xpub`, which digests ZIP-243 (Sapling) and would - /// report `Ok(false)` for a valid v6 signature. Only v6 (Ironwood) Zcash PSBTs are - /// supported. - pub fn verify_ironwood_v6_signature_with_xpub( - &self, - input_index: usize, - xpub: &WasmBIP32, - ) -> Result { - let xpub_inner = xpub.to_xpub()?; - let secp = miniscript::bitcoin::secp256k1::Secp256k1::verification_only(); - self.psbt - .verify_v6_signature_with_xpub(&secp, input_index, &xpub_inner) - .map_err(Into::into) - } - - /// Verify if a valid signature exists for a given ECPair key at the specified input index, - /// computed over the ZIP-244 v6 (Ironwood) transparent sighash — the v6 (Ironwood) - /// counterpart to `verify_signature_with_pub`, which digests ZIP-243 (Sapling) and would - /// report `Ok(false)` for a valid v6 signature. Only v6 (Ironwood) Zcash PSBTs are - /// supported. - pub fn verify_ironwood_v6_signature_with_pub( - &self, - input_index: usize, - ecpair: &WasmECPair, - ) -> Result { - let public_key = ecpair.get_public_key(); - let secp = miniscript::bitcoin::secp256k1::Secp256k1::verification_only(); - self.psbt - .verify_v6_signature_with_pub(&secp, input_index, &public_key) - .map_err(Into::into) - } - /// Verify if a replay protection input has a valid signature /// /// This method checks if a given input is a replay protection input and cryptographically verifies @@ -2363,24 +1777,6 @@ impl BitGoPsbt { Ok(crate::wasm::dash_transaction::WasmDashTransaction::from_parts(parts)) } - /// Extract the final transaction as a WasmZcashTransaction (for Zcash networks) - /// - /// This avoids re-parsing bytes by returning the transaction directly. - /// Only valid for Zcash networks. - pub fn extract_zcash_transaction( - &self, - max_fee_rate_sat_per_vb: Option, - ) -> Result { - let parts = self - .psbt - .clone() - .extract_zcash_tx_with_fee_policy(fee_policy_from_js(max_fee_rate_sat_per_vb)) - .map_err(|e| WasmUtxoError::new(&e))?; - Ok(crate::wasm::transaction::WasmZcashTransaction::from_parts( - parts, - )) - } - /// Extract a half-signed transaction in legacy format for p2ms-based script types. /// /// This method extracts a transaction where each input has exactly one signature, @@ -2415,5 +1811,5 @@ impl BitGoPsbt { impl_wasm_psbt_ops!(BitGoPsbt, psbt); -// Zcash-scoped bindings (`zcash_branch_id_for_height`, `ZcashUnifiedAddress`, …) -// live in `crate::wasm::zcash`. +// Zcash `BitGoPsbt` methods live in `zcash_psbt.rs`; Zcash-scoped bindings +// (`zcash_branch_id_for_height`, `ZcashUnifiedAddress`, …) live in `crate::wasm::zcash`. 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 new file mode 100644 index 00000000000..fd057d9612d --- /dev/null +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/zcash_psbt.rs @@ -0,0 +1,692 @@ +//! Zcash-specific wasm bindings for [`BitGoPsbt`]: constructors, header getters, Ironwood +//! shielded-output methods, v6 signature verification and extraction, plus the JS conversion +//! for the Zcash-only `IronwoodOutputRequest`. +//! +//! Split out of `wasm/fixed_script_wallet/mod.rs`; wasm-bindgen merges this `impl` with +//! the generic one, so the JS class and method names are unchanged. + +use std::collections::HashMap; +use wasm_bindgen::prelude::*; +use wasm_bindgen::JsValue; + +use super::fee_policy_from_js; +use super::parse_network; +use super::BitGoPsbt; +use crate::error::WasmUtxoError; +use crate::fixed_script_wallet::bitgo_psbt::zcash::IronwoodOutputRequest; +use crate::wasm::bip32::WasmBIP32; +use crate::wasm::ecpair::WasmECPair; +use crate::wasm::try_from_js_value::Bytes; +use crate::wasm::try_from_js_value::TryFromJsValue; +use crate::wasm::wallet_keys::WasmRootWalletKeys; +use crate::zcash::ironwood_build::{ + AnchorBytes, MemoBytes, OrchardAddressBytes, OvkBytes, ANCHOR_SIZE, MEMO_SIZE, + ORCHARD_ADDRESS_SIZE, OVK_SIZE, +}; + +impl BitGoPsbt { + /// Borrow the inner `ZcashBitGoPsbt`, erroring if this is not a Zcash PSBT. + fn zcash( + &self, + ) -> Result<&crate::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt, WasmUtxoError> { + use crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt as InnerBitGoPsbt; + match &self.psbt { + InnerBitGoPsbt::Zcash(z, _) => Ok(z), + _ => Err(WasmUtxoError::new("not a Zcash PSBT")), + } + } + + /// Mutably borrow the inner `ZcashBitGoPsbt`, erroring if this is not a Zcash PSBT. + fn zcash_mut( + &mut self, + ) -> Result<&mut crate::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt, WasmUtxoError> { + use crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt as InnerBitGoPsbt; + match &mut self.psbt { + InnerBitGoPsbt::Zcash(z, _) => Ok(z), + _ => Err(WasmUtxoError::new("not a Zcash PSBT")), + } + } +} + +#[wasm_bindgen] +impl BitGoPsbt { + /// Create an empty Zcash PSBT with the required consensus branch ID + /// + /// This method is specifically for Zcash networks which require additional + /// parameters for sighash computation. + /// + /// # Arguments + /// * `network` - Network name (must be "zcash" or "zcashTest") + /// * `wallet_keys` - The wallet's root keys (used to set global xpubs) + /// * `consensus_branch_id` - Zcash consensus branch ID (e.g., 0xC2D6D0B4 for NU5) + /// * `version` - Optional transaction version (default: 4 for Zcash Sapling+) + /// * `lock_time` - Optional lock time (default: 0) + /// * `version_group_id` - Optional version group ID (defaults to Sapling: 0x892F2085) + /// * `expiry_height` - Optional expiry height + pub fn create_empty_zcash( + network: &str, + wallet_keys: &WasmRootWalletKeys, + consensus_branch_id: u32, + version: Option, + lock_time: Option, + version_group_id: Option, + expiry_height: Option, + ) -> Result { + let network = parse_network(network)?; + let wallet_keys = wallet_keys.inner(); + + let psbt = crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::new_zcash( + network, + wallet_keys, + consensus_branch_id, + version, + lock_time, + version_group_id, + expiry_height, + ); + + Ok(BitGoPsbt { + psbt, + first_rounds: HashMap::new(), + }) + } + + /// Create an empty Zcash PSBT with consensus branch ID determined from block height + /// + /// This method automatically determines the correct consensus branch ID based on + /// the network and block height using the network upgrade activation heights. + /// + /// # Arguments + /// * `network` - Network name (must be "zcash" or "zcashTest") + /// * `wallet_keys` - The wallet's root keys (used to set global xpubs) + /// * `block_height` - Block height to determine consensus rules + /// * `version` - Optional transaction version (default: 4 for Zcash Sapling+) + /// * `lock_time` - Optional lock time (default: 0) + /// * `version_group_id` - Optional version group ID (defaults to Sapling: 0x892F2085) + /// * `expiry_height` - Optional expiry height + /// + /// # Errors + /// Returns error if block height is before Overwinter activation, or if the height is + /// at or after NU7 activation and the build would be a version 4 transaction — version 4 + /// is invalid under NU7 per ZIP-2003; use `create_empty_zcash_v6_at_height` instead + #[allow(clippy::too_many_arguments)] + pub fn create_empty_zcash_at_height( + network: &str, + wallet_keys: &WasmRootWalletKeys, + block_height: u32, + version: Option, + lock_time: Option, + version_group_id: Option, + expiry_height: Option, + ) -> Result { + let network = parse_network(network)?; + let wallet_keys = wallet_keys.inner(); + + let psbt = crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::new_zcash_at_height( + network, + wallet_keys, + block_height, + version, + lock_time, + version_group_id, + expiry_height, + ) + .map_err(|e| WasmUtxoError::new(&e))?; + + Ok(BitGoPsbt { + psbt, + first_rounds: HashMap::new(), + }) + } + + // ---- Zcash v6 (Ironwood / NU6.3) shielding ---- + + /// Create an empty Zcash **v6 (Ironwood)** shielding PSBT with an explicit consensus branch id. + /// + /// The version group id is fixed by the v6 format, but the consensus branch id tracks the active + /// network upgrade and stays caller-supplied — the v6 analogue of `create_empty_zcash`. Prefer + /// `create_empty_zcash_v6_at_height`, which derives it from a block height and rejects heights + /// before NU6.3 activation; this is the escape hatch for callers that already know the value. + pub fn create_empty_zcash_v6( + network: &str, + wallet_keys: &WasmRootWalletKeys, + consensus_branch_id: u32, + lock_time: Option, + expiry_height: Option, + ) -> Result { + let network = parse_network(network)?; + let wallet_keys = wallet_keys.inner(); + let psbt = crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::new_zcash_v6( + network, + wallet_keys, + consensus_branch_id, + lock_time, + expiry_height, + ); + Ok(BitGoPsbt { + psbt, + first_rounds: HashMap::new(), + }) + } + + /// Create an empty Zcash **v6 (Ironwood)** shielding PSBT, with the consensus branch id + /// resolved from `block_height`. Transparent inputs/outputs are added with the usual + /// `add_wallet_input` / `add_wallet_output`; the shielded output and the v6 sign/combine + /// steps use the dedicated `add_ironwood_output` / `ironwood_v6_*` / `combine_ironwood_proof` + /// methods. + pub fn create_empty_zcash_v6_at_height( + network: &str, + wallet_keys: &WasmRootWalletKeys, + block_height: u32, + lock_time: Option, + expiry_height: Option, + ) -> Result { + let network = parse_network(network)?; + let wallet_keys = wallet_keys.inner(); + let psbt = crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::new_zcash_v6_at_height( + network, + wallet_keys, + 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; + match &self.psbt { + InnerBitGoPsbt::Zcash(zcash_psbt, _) => zcash_psbt.version_group_id, + InnerBitGoPsbt::BitcoinLike(_, _) => None, + InnerBitGoPsbt::Dash(_, _) => None, + } + } + + /// Get the Zcash expiry height (returns None for non-Zcash PSBTs) + pub fn expiry_height(&self) -> Option { + use crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt as InnerBitGoPsbt; + match &self.psbt { + InnerBitGoPsbt::Zcash(zcash_psbt, _) => zcash_psbt.expiry_height, + InnerBitGoPsbt::BitcoinLike(_, _) => None, + InnerBitGoPsbt::Dash(_, _) => None, + } + } + + /// Get the Zcash consensus branch ID from the PSBT proprietary map (returns None for non-Zcash PSBTs) + pub fn consensus_branch_id(&self) -> Option { + use crate::fixed_script_wallet::bitgo_psbt::{ + zcash::propkv::get_zec_consensus_branch_id, BitGoPsbt as InnerBitGoPsbt, + }; + match &self.psbt { + InnerBitGoPsbt::Zcash(z, _) => get_zec_consensus_branch_id(&z.psbt), + _ => None, + } + } + + /// Constructor: add the shielded Ironwood output as an orchard PCZT stored in the PSBT. + /// + /// * `recipient` - raw Orchard/Ironwood address ([`ORCHARD_ADDRESS_SIZE`] bytes) + /// * `amount` - note value in zatoshi + /// * `ovk` - optional outgoing viewing key ([`OVK_SIZE`] bytes; `None` for a keyless build). + /// Has no effect in the normal flow: `sign_ironwood_v6`'s first round re-encrypts every + /// action's `out_ciphertext` under the wallet `ovk`, replacing whatever this produced + /// * `anchor` - Ironwood note-commitment-tree root ([`ANCHOR_SIZE`] bytes) + /// * `memo` - ZIP-302 memo field ([`MEMO_SIZE`] bytes) + /// * `unified_address` - optional full Unified Address the output was addressed to; if given, + /// its Orchard receiver must match `recipient`, and it is stored verbatim so output parsing + /// can later return it in full (transparent/Sapling receivers included) instead of + /// reconstructing a single-receiver UA from `recipient` alone + /// + /// [`ORCHARD_ADDRESS_SIZE`]: crate::zcash::ironwood_build::ORCHARD_ADDRESS_SIZE + /// [`OVK_SIZE`]: crate::zcash::ironwood_build::OVK_SIZE + /// [`ANCHOR_SIZE`]: crate::zcash::ironwood_build::ANCHOR_SIZE + /// [`MEMO_SIZE`]: crate::zcash::ironwood_build::MEMO_SIZE + pub fn add_ironwood_output( + &mut self, + recipient: &[u8], + amount: u64, + ovk: Option>, + anchor: &[u8], + memo: &[u8], + unified_address: Option, + ) -> Result<(), WasmUtxoError> { + /// Length-check a boundary byte string, naming the field and its expected size in the error. + fn fixed(bytes: &[u8], field: &str) -> Result<[u8; N], WasmUtxoError> { + bytes.try_into().map_err(|_| { + WasmUtxoError::new(&format!("{field} must be {N} bytes, got {}", bytes.len())) + }) + } + + let recipient: OrchardAddressBytes = fixed::(recipient, "recipient")?; + let anchor: AnchorBytes = fixed::(anchor, "anchor")?; + let memo: MemoBytes = fixed::(memo, "memo")?; + let ovk: Option = ovk.map(|v| fixed::(&v, "ovk")).transpose()?; + self.zcash_mut()? + .add_ironwood_output( + &recipient, + amount, + ovk, + &anchor, + &memo, + unified_address.as_deref(), + rand::rngs::OsRng, + ) + .map(|_action_index| ()) + .map_err(|e| WasmUtxoError::new(&e)) + } + + /// Constructor: add one or more shielded Ironwood outputs as a single orchard PCZT stored in + /// the PSBT — the multi-recipient counterpart to [`Self::add_ironwood_output`]. + /// + /// `outputs` is an array of `{ recipient: Uint8Array, amount: bigint, memo: Uint8Array, + /// ovk?: Uint8Array, unifiedAddress?: string }`, one entry per recipient — see + /// [`Self::add_ironwood_output`] for the meaning of each field. `anchor` is shared by every + /// output. + /// + /// Returns the action index assigned to each output, in the same order as `outputs` — the + /// orchard builder pads/reorders actions, so a caller correlating per-action data (a stored + /// Unified Address, an explicit `set_ironwood_out_ciphertext` call) must use these indices, not + /// the position in `outputs` — though note `sign_ironwood_v6`'s first round already patches + /// every action's `out_ciphertext` with the wallet `ovk` on its own. + pub fn add_ironwood_outputs( + &mut self, + outputs: JsValue, + anchor: &[u8], + ) -> Result, WasmUtxoError> { + let anchor: AnchorBytes = anchor.try_into().map_err(|_| { + WasmUtxoError::new(&format!( + "anchor must be {ANCHOR_SIZE} bytes, got {}", + anchor.len() + )) + })?; + + let arr = js_sys::Array::from(&outputs); + let requests = arr + .iter() + .map(|item| IronwoodOutputRequest::try_from_js_value(&item)) + .collect::, _>>()?; + + self.zcash_mut()? + .add_ironwood_outputs(&requests, &anchor, rand::rngs::OsRng) + .map(|indices| indices.into_iter().map(|i| i as u32).collect()) + .map_err(|e| WasmUtxoError::new(&e)) + } + + /// Client-managed `ovk`: re-encrypt the Ironwood output's `out_ciphertext` under this wallet's + /// `ovk`, derived as the ECDH agreement of `root_wallet_keys.bitgo_key()` and `user_xpriv` — two + /// root keys, so the result is transaction-independent and the server can re-derive the identical + /// `ovk` from its own privkey plus the user's pubkey in order to validate `out_ciphertext`. The + /// server never sees `user_xpriv` or the `ovk` itself. + /// + /// `user_xpriv` must be the wallet's user root key; any other key is rejected, since an `ovk` + /// derived from it is one neither the user nor the server can reproduce. + /// + /// Must be called before signing: `out_ciphertext` is ZIP-244 sighash-committed, so calling this + /// after any transparent signature has been collected for the PSBT is rejected (it would + /// silently invalidate that signature). + pub fn set_ironwood_out_ciphertext( + &mut self, + action_index: usize, + user_xpriv: &WasmBIP32, + root_wallet_keys: &WasmRootWalletKeys, + ) -> Result<(), WasmUtxoError> { + let user_xpriv = user_xpriv.to_xpriv()?; + let secp = miniscript::bitcoin::secp256k1::Secp256k1::new(); + self.zcash_mut()? + .set_ironwood_out_ciphertext_for_user( + action_index, + &user_xpriv, + root_wallet_keys.inner(), + &secp, + ) + .map_err(|e| WasmUtxoError::new(&e)) + } + + /// The canonical (display-order) ZIP-244 v6 txid as a lowercase hex string, matching the + /// `getId()` convention used elsewhere (see [`crate::wasm::zcash::ZcashV6Transaction::get_id`]). + pub fn ironwood_v6_txid(&self) -> Result { + use miniscript::bitcoin::hashes::Hash; + let txid = self + .zcash()? + .v6_txid() + .map_err(|e| WasmUtxoError::new(&e))?; + Ok(miniscript::bitcoin::Txid::from_byte_array(txid).to_string()) + } + + /// The ZIP-244 per-input transparent sighash (32 bytes) that the key controlling transparent + /// input `index` must sign. + pub fn ironwood_v6_transparent_sighash(&self, index: usize) -> Result, WasmUtxoError> { + self.zcash()? + .v6_transparent_sighash(index) + .map(|h| h.to_vec()) + .map_err(|e| WasmUtxoError::new(&e)) + } + + /// Ingest a transparent-input signature (`sig`: DER ECDSA with the trailing SIGHASH_ALL byte) + /// after verifying it against `ironwood_v6_transparent_sighash(index)`. + pub fn add_ironwood_v6_signature( + &mut self, + index: usize, + pubkey: &[u8], + sig: &[u8], + ) -> Result<(), WasmUtxoError> { + let pk = miniscript::bitcoin::PublicKey::from_slice(pubkey) + .map_err(|e| WasmUtxoError::new(&format!("invalid pubkey: {e}")))?; + self.zcash_mut()? + .add_v6_transparent_signature(index, pk, sig) + .map_err(|e| WasmUtxoError::new(&e)) + } + + /// Sign every transparent input `xpriv` resolves a key for, over the ZIP-244 transparent + /// sighash — the v6 (Ironwood) counterpart to `sign_all_wallet_inputs`, which rejects v6 PSBTs + /// (it only knows the ZIP-243 digest). + /// + /// If no transparent signature has been collected yet, this is the first signing round and + /// `xpriv` must be the wallet's user root key: it is used with `root_wallet_keys.bitgo_key()` to + /// derive this wallet's `ovk` and finalize every action's `out_ciphertext` (multi-recipient + /// builds included) before computing any sighash (it is + /// sighash-committed). Any other key signing first is rejected, rather than deriving an `ovk` + /// nobody can reproduce. Once a signature exists, the step is skipped and any key may sign — so + /// callers pass `root_wallet_keys` unconditionally on every signing round without needing to know + /// which key is signing; it is mandatory precisely so the step can never be skipped by omission. + /// + /// Returns the indices of the transparent inputs that were signed. + pub fn sign_ironwood_v6( + &mut self, + xpriv: &WasmBIP32, + root_wallet_keys: &WasmRootWalletKeys, + ) -> Result, WasmUtxoError> { + let xpriv = xpriv.to_xpriv()?; + let secp = miniscript::bitcoin::secp256k1::Secp256k1::new(); + self.zcash_mut()? + .sign_ironwood_v6(&xpriv, root_wallet_keys.inner(), &secp) + .map_err(|e| WasmUtxoError::new(&e)) + } + + /// Transaction Extractor: given the external prover's `proof` bytes, finalize the transparent + /// inputs, apply the shielded binding signature, and return the broadcast-ready v6 transaction + /// bytes. Requires the transparent inputs to be signed (via `add_ironwood_v6_signature`). + /// + /// Terminal: on success the stored PCZT is dropped, so a second call — or any other Ironwood + /// operation, including after a `serialize`/`from_bytes` round-trip — fails rather than silently + /// re-running against already-extracted state. Build a fresh PSBT instead. On failure nothing is + /// dropped, so a call that errors (bad proof, unsigned input) leaves the PSBT retryable. + pub fn combine_ironwood_proof(&mut self, proof: &[u8]) -> Result, WasmUtxoError> { + // The inner `combine_ironwood_proof` takes `self` by value (it is a consuming Extractor + // step), which does not translate across the wasm boundary — so clone, and mark this + // instance spent only once the clone has actually produced a transaction. + let tx = self + .zcash()? + .clone() + .combine_ironwood_proof(proof.to_vec(), rand::rngs::OsRng) + .map_err(|e| WasmUtxoError::new(&e))?; + self.zcash_mut()?.mark_ironwood_extracted(); + Ok(tx) + } + + /// Transaction Extractor for a transparent-only v6 (Ironwood) PSBT: no shielded output was + /// ever added, so there is no PCZT and nothing to combine an external proof into. Returns + /// broadcast-ready v6 transaction bytes with an empty Ironwood bundle slot. + /// + /// Not terminal (unlike `combine_ironwood_proof`): there is no stored PCZT to drop, so + /// calling this more than once is safe. + /// + /// Errors if a shielded output was ever added to this PSBT, or if any transparent input is + /// missing a required signature. See + /// [`crate::fixed_script_wallet::bitgo_psbt::zcash::ZcashBitGoPsbt::extract_transparent_only_v6_tx`]. + pub fn extract_transparent_only_v6_tx(&self) -> Result, WasmUtxoError> { + self.zcash()? + .extract_transparent_only_v6_tx() + .map_err(|e| WasmUtxoError::new(&e)) + } + + /// The raw serialized orchard PCZT (Partially Created Zcash Transaction) bundle stored in + /// this v6 (Ironwood) PSBT's proprietary key-value map, or `undefined` if none is present. + /// + /// A PCZT is the shielded-bundle counterpart to a PSBT: it accumulates the orchard action + /// (spend + output), the ZIP-244-committed `out_ciphertext`, and — once + /// [`Self::combine_ironwood_proof`] has run — the Halo2 `zkproof` and binding signature, + /// bridging this PSBT to the external proof service and back (see + /// [`crate::zcash::ironwood_pczt`] / [`crate::zcash::ironwood_build`]). + /// + /// Present only after [`Self::add_ironwood_output`] has been called on a v6 PSBT; `undefined` + /// beforehand, and `undefined` again after [`Self::combine_ironwood_proof`] succeeds, since + /// that call drops the stored PCZT to make extraction terminal (see its doc comment). + pub fn ironwood_pczt_bytes(&self) -> Option> { + self.zcash().ok().and_then(|z| z.raw_ironwood_pczt_bytes()) + } + + /// Convert a network-format Zcash transaction (0, 1, or 2 sigs) to a PSBT (with block height). + /// + /// Accepts unsigned, half-signed, and fully-signed Zcash transactions. + /// + /// # Arguments + /// * `tx` - The decoded Zcash transaction + /// * `network` - Network name ("zec" or "tzec") + /// * `wallet_keys` - The wallet's root keys + /// * `unspents` - Array of `{ chain: number, index: number, value: bigint }` for each input + /// * `block_height` - Block height to determine consensus branch ID + #[wasm_bindgen] + pub fn from_network_format_zcash_with_block_height( + tx: &crate::wasm::transaction::WasmZcashTransaction, + network: &str, + wallet_keys: &WasmRootWalletKeys, + unspents: JsValue, + block_height: u32, + ) -> Result { + let network_parsed = parse_network(network)?; + let is_mainnet = matches!(network_parsed, crate::networks::Network::Zcash); + + let consensus_branch_id = crate::zcash::branch_id_for_height(block_height, is_mainnet) + .ok_or_else(|| { + WasmUtxoError::new(&format!( + "Block height {} is before Overwinter activation on {}", + block_height, + if is_mainnet { "mainnet" } else { "testnet" } + )) + })?; + + Self::from_network_format_zcash_with_branch_id( + tx, + network, + wallet_keys, + unspents, + consensus_branch_id, + ) + } + + /// Convert a network-format Zcash transaction (0, 1, or 2 sigs) to a PSBT (with consensus branch ID). + /// + /// Accepts unsigned, half-signed, and fully-signed Zcash transactions. + /// + /// # Arguments + /// * `tx` - The decoded Zcash transaction + /// * `network` - Network name ("zec" or "tzec") + /// * `wallet_keys` - The wallet's root keys + /// * `unspents` - Array of `{ chain: number, index: number, value: bigint }` for each input + /// * `consensus_branch_id` - Zcash consensus branch ID + #[wasm_bindgen] + pub fn from_network_format_zcash_with_branch_id( + tx: &crate::wasm::transaction::WasmZcashTransaction, + network: &str, + wallet_keys: &WasmRootWalletKeys, + unspents: JsValue, + consensus_branch_id: u32, + ) -> Result { + use crate::fixed_script_wallet::bitgo_psbt::HydrationUnspentInput; + + let network = parse_network(network)?; + let wallet_keys = wallet_keys.inner(); + + let arr = js_sys::Array::from(&unspents); + let parsed_unspents = arr + .iter() + .map(|item| HydrationUnspentInput::try_from_js_value(&item)) + .collect::, _>>()?; + + let zcash = crate::fixed_script_wallet::bitgo_psbt::ZcashBitGoPsbt::from_network_format( + &tx.parts, + network, + wallet_keys, + &parsed_unspents, + consensus_branch_id, + ) + .map_err(|e| WasmUtxoError::new(&e))?; + + Ok(BitGoPsbt { + psbt: crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::Zcash(zcash, network), + first_rounds: HashMap::new(), + }) + } + + /// Zcash-only: add a plain transparent output. Just `script`/`value` + /// (`unified_address: undefined`) is the legacy `add_output` behavior, unchanged. + /// + /// If `unified_address` is given, this PSBT must be a legacy v4 Zcash PSBT — a v6 (Ironwood) + /// PSBT's shielded side already has its own UA path (`add_ironwood_output`). The UA must be a + /// Unified Address whose transparent receiver is exactly `script`; mismatches are rejected + /// rather than silently stored. It is then kept verbatim, keyed by this output's index, so + /// `transparent_output_unified_address` can later return the original UA string rather than + /// just the bare scriptPubkey. + pub fn add_transparent_output( + &mut self, + script: &[u8], + value: u64, + unified_address: Option, + ) -> Result { + use miniscript::bitcoin::ScriptBuf; + let script = ScriptBuf::from_bytes(script.to_vec()); + self.zcash_mut()? + .add_transparent_output(script, value, unified_address.as_deref()) + .map_err(|e| WasmUtxoError::new(&e)) + } + + /// Zcash-only: the Unified Address stored by [`Self::add_transparent_output`] for output + /// `index`, if the caller supplied one — `undefined` otherwise (including for a non-Zcash + /// PSBT). + pub fn transparent_output_unified_address(&self, index: usize) -> Option { + self.zcash() + .ok() + .and_then(|z| z.transparent_output_unified_address(index)) + } + + /// Verify if a valid signature exists for an extended public key at the specified input + /// index, computed over the ZIP-244 v6 (Ironwood) transparent sighash — the v6 (Ironwood) + /// counterpart to `verify_signature_with_xpub`, which digests ZIP-243 (Sapling) and would + /// report `Ok(false)` for a valid v6 signature. Only v6 (Ironwood) Zcash PSBTs are + /// supported. + pub fn verify_ironwood_v6_signature_with_xpub( + &self, + input_index: usize, + xpub: &WasmBIP32, + ) -> Result { + let xpub_inner = xpub.to_xpub()?; + let secp = miniscript::bitcoin::secp256k1::Secp256k1::verification_only(); + self.psbt + .verify_v6_signature_with_xpub(&secp, input_index, &xpub_inner) + .map_err(Into::into) + } + + /// Verify if a valid signature exists for a given ECPair key at the specified input index, + /// computed over the ZIP-244 v6 (Ironwood) transparent sighash — the v6 (Ironwood) + /// counterpart to `verify_signature_with_pub`, which digests ZIP-243 (Sapling) and would + /// report `Ok(false)` for a valid v6 signature. Only v6 (Ironwood) Zcash PSBTs are + /// supported. + pub fn verify_ironwood_v6_signature_with_pub( + &self, + input_index: usize, + ecpair: &WasmECPair, + ) -> Result { + let public_key = ecpair.get_public_key(); + let secp = miniscript::bitcoin::secp256k1::Secp256k1::verification_only(); + self.psbt + .verify_v6_signature_with_pub(&secp, input_index, &public_key) + .map_err(Into::into) + } + + /// Extract the final transaction as a WasmZcashTransaction (for Zcash networks) + /// + /// This avoids re-parsing bytes by returning the transaction directly. + /// Only valid for Zcash networks. + pub fn extract_zcash_transaction( + &self, + max_fee_rate_sat_per_vb: Option, + ) -> Result { + let parts = self + .psbt + .clone() + .extract_zcash_tx_with_fee_policy(fee_policy_from_js(max_fee_rate_sat_per_vb)) + .map_err(|e| WasmUtxoError::new(&e))?; + Ok(crate::wasm::transaction::WasmZcashTransaction::from_parts( + parts, + )) + } +} + +// ============================================================================= +// IronwoodOutputRequest: one requested multi-recipient Ironwood shielded output +// ============================================================================= + +impl TryFromJsValue for IronwoodOutputRequest { + fn try_from_js_value(item: &JsValue) -> Result { + let recipient_val = js_sys::Reflect::get(item, &"recipient".into()) + .map_err(|_| WasmUtxoError::new("Missing 'recipient' field on Ironwood output"))?; + if recipient_val.is_undefined() { + return Err(WasmUtxoError::new( + "Missing 'recipient' field on Ironwood output", + )); + } + let recipient: OrchardAddressBytes = Bytes::<43>::try_from_js_value(&recipient_val)?.into(); + + let amount_val = js_sys::Reflect::get(item, &"amount".into()) + .map_err(|_| WasmUtxoError::new("Missing 'amount' field on Ironwood output"))?; + if amount_val.is_undefined() { + return Err(WasmUtxoError::new( + "Missing 'amount' field on Ironwood output", + )); + } + if !amount_val.is_bigint() { + return Err(WasmUtxoError::new("'amount' must be a bigint")); + } + let amount = u64::try_from(js_sys::BigInt::unchecked_from_js(amount_val)) + .map_err(|_| WasmUtxoError::new("'amount' must be a bigint convertible to u64"))?; + + let memo_val = js_sys::Reflect::get(item, &"memo".into()) + .map_err(|_| WasmUtxoError::new("Missing 'memo' field on Ironwood output"))?; + if memo_val.is_undefined() { + return Err(WasmUtxoError::new( + "Missing 'memo' field on Ironwood output", + )); + } + let memo: MemoBytes = Bytes::<512>::try_from_js_value(&memo_val)?.into(); + + let ovk_val = js_sys::Reflect::get(item, &"ovk".into()).unwrap_or(JsValue::UNDEFINED); + let ovk: Option = if ovk_val.is_undefined() || ovk_val.is_null() { + None + } else { + Some(Bytes::<32>::try_from_js_value(&ovk_val)?.into()) + }; + + let unified_address_val = + js_sys::Reflect::get(item, &"unifiedAddress".into()).unwrap_or(JsValue::UNDEFINED); + let unified_address = if unified_address_val.is_undefined() || unified_address_val.is_null() + { + None + } else { + Some(String::try_from_js_value(&unified_address_val)?) + }; + + Ok(IronwoodOutputRequest { + recipient, + amount, + ovk, + memo, + unified_address, + }) + } +} diff --git a/packages/wasm-utxo/src/wasm/try_from_js_value.rs b/packages/wasm-utxo/src/wasm/try_from_js_value.rs index 95b997c9d5a..067343fd4d5 100644 --- a/packages/wasm-utxo/src/wasm/try_from_js_value.rs +++ b/packages/wasm-utxo/src/wasm/try_from_js_value.rs @@ -2,7 +2,6 @@ use std::ops::Deref; use crate::address::utxolib_compat::{CashAddr, UtxolibNetwork}; use crate::error::WasmUtxoError; -use crate::zcash::ironwood_build::{MemoBytes, OrchardAddressBytes, OvkBytes}; use miniscript::bitcoin::psbt::raw; use wasm_bindgen::{JsCast, JsValue}; @@ -292,68 +291,3 @@ impl TryFromJsValue for crate::fixed_script_wallet::bitgo_psbt::HydrationUnspent } } } - -// ============================================================================= -// IronwoodOutputRequest: one requested multi-recipient Ironwood shielded output -// ============================================================================= - -impl TryFromJsValue for crate::fixed_script_wallet::bitgo_psbt::zcash_psbt::IronwoodOutputRequest { - fn try_from_js_value(item: &JsValue) -> Result { - let recipient_val = js_sys::Reflect::get(item, &"recipient".into()) - .map_err(|_| WasmUtxoError::new("Missing 'recipient' field on Ironwood output"))?; - if recipient_val.is_undefined() { - return Err(WasmUtxoError::new( - "Missing 'recipient' field on Ironwood output", - )); - } - let recipient: OrchardAddressBytes = Bytes::<43>::try_from_js_value(&recipient_val)?.into(); - - let amount_val = js_sys::Reflect::get(item, &"amount".into()) - .map_err(|_| WasmUtxoError::new("Missing 'amount' field on Ironwood output"))?; - if amount_val.is_undefined() { - return Err(WasmUtxoError::new( - "Missing 'amount' field on Ironwood output", - )); - } - if !amount_val.is_bigint() { - return Err(WasmUtxoError::new("'amount' must be a bigint")); - } - let amount = u64::try_from(js_sys::BigInt::unchecked_from_js(amount_val)) - .map_err(|_| WasmUtxoError::new("'amount' must be a bigint convertible to u64"))?; - - let memo_val = js_sys::Reflect::get(item, &"memo".into()) - .map_err(|_| WasmUtxoError::new("Missing 'memo' field on Ironwood output"))?; - if memo_val.is_undefined() { - return Err(WasmUtxoError::new( - "Missing 'memo' field on Ironwood output", - )); - } - let memo: MemoBytes = Bytes::<512>::try_from_js_value(&memo_val)?.into(); - - let ovk_val = js_sys::Reflect::get(item, &"ovk".into()).unwrap_or(JsValue::UNDEFINED); - let ovk: Option = if ovk_val.is_undefined() || ovk_val.is_null() { - None - } else { - Some(Bytes::<32>::try_from_js_value(&ovk_val)?.into()) - }; - - let unified_address_val = - js_sys::Reflect::get(item, &"unifiedAddress".into()).unwrap_or(JsValue::UNDEFINED); - let unified_address = if unified_address_val.is_undefined() || unified_address_val.is_null() - { - None - } else { - Some(String::try_from_js_value(&unified_address_val)?) - }; - - Ok( - crate::fixed_script_wallet::bitgo_psbt::zcash_psbt::IronwoodOutputRequest { - recipient, - amount, - ovk, - memo, - unified_address, - }, - ) - } -}