From 65d1f75b2348e0ce52c3e69fc26da213e413957d Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Mon, 10 Aug 2026 12:06:35 +0530 Subject: [PATCH] feat(wasm-utxo): surface Ironwood (v6) shielded output in PSBT inspection Ticket: CSHLD-1433 --- .../bitgo_psbt/zcash_psbt.rs | 107 +++++++ packages/wasm-utxo/src/inspect/psbt.rs | 296 ++++++++++++++++++ 2 files changed, 403 insertions(+) diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs index 87700f068b4..da862a67d61 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash_psbt.rs @@ -925,6 +925,50 @@ impl ZcashBitGoPsbt { .map_err(|e| e.to_string()) } + /// The value (zatoshi) and raw 43-byte recipient of the shielded Ironwood output, if one has + /// been added via [`Self::add_ironwood_output`] — `None` if no shielded output is present. + /// + /// The shielded side lives in a proprietary-map PCZT rather than `unsigned_tx.output`, so + /// transparent-only output parsing (`ParsedOutput`/`sum_output_values`) never sees it; this is + /// how callers (e.g. inspection tooling) surface it explicitly. + /// + /// The recipient is readable here — unlike the on-wire `IronwoodAction` + /// ([`crate::zcash::ironwood_build::action_to_ironwood`], which only carries the *encrypted* + /// note — because the PCZT keeps the Constructor's plaintext `recipient`/`value` fields + /// in-memory for the Prover/Signer roles; nothing needs decrypting. + /// + /// Only [`Self::add_ironwood_output`]'s single-output shape (dummy spend, one real output) is + /// supported elsewhere in this file, so this always reads action 0 — and only because + /// `construct_shield_pczt` builds with `BundleType::UNPADDED`, guaranteed to produce exactly + /// one action. + pub fn ironwood_shielded_output_info( + &self, + ) -> Result, String> { + if super::propkv::get_ironwood_pczt(&self.psbt).is_none() { + return Ok(None); + } + // `orchard::pczt::Bundle` only exposes a mutable actions accessor; we only read from it. + let mut pczt = self.ironwood_pczt()?; + let actions = pczt.actions_mut(); + if actions.len() != 1 { + return Err(format!( + "expected exactly one Ironwood action (single-output shape), found {}", + actions.len() + )); + } + let action = &actions[0]; + let output = action.output(); + let value = output + .value() + .map(|v| v.inner()) + .ok_or_else(|| "shielded output is missing its plaintext value".to_string())?; + let recipient = output + .recipient() + .map(|address| address.to_raw_address_bytes()) + .ok_or_else(|| "shielded output is missing its plaintext recipient".to_string())?; + Ok(Some((value, recipient))) + } + /// The spent-output value (zatoshi, as i64) and scriptPubKey of every transparent input, in /// input order — the amounts/scripts ZIP-244 commits to. fn transparent_input_amounts_and_scripts( @@ -2254,6 +2298,69 @@ mod ironwood_v6_tests { ); } + /// `ironwood_shielded_output_info` returns `None` before a shielded output has been added, and + /// the exact (value, recipient) passed to `add_ironwood_output` afterwards — regardless of + /// whether the transparent input has been signed yet, since it reads the PCZT's plaintext + /// fields rather than anything sighash/signature-dependent. + #[test] + fn ironwood_shielded_output_info_reflects_added_output() { + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys("v6_output_info")); + let mut psbt = BitGoPsbt::new_zcash_v6_at_height( + Network::ZcashTestnet, + &wallet_keys, + NetworkUpgrade::Nu6_3.testnet_activation_height(), + None, + None, + ) + .unwrap(); + psbt.add_wallet_input( + Txid::from_byte_array([0x44u8; 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"); + }; + + // No shielded output yet (unsigned transparent skeleton). + assert!(z.ironwood_shielded_output_info().unwrap().is_none()); + + let recipient = test_recipient(); + z.add_ironwood_output( + &recipient, + 100_000_000, + None, + &Anchor::empty_tree().to_bytes(), + &[0u8; 512], + OsRng, + ) + .unwrap(); + let (value, got_recipient) = z.ironwood_shielded_output_info().unwrap().unwrap(); + assert_eq!(value, 100_000_000); + assert_eq!(got_recipient, recipient); + + // Still reflects the same output once the transparent input is (half/fully) signed — the + // shielded output info does not depend on transparent signing state. + let secp = Secp256k1::new(); + let sighash = z.v6_transparent_sighash(0).unwrap(); + let msg = Message::from_digest(sighash); + let sk = signing_secret_keys("v6_output_info", 0, 0)[0]; + 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(); + let (value, got_recipient) = z.ironwood_shielded_output_info().unwrap().unwrap(); + assert_eq!(value, 100_000_000); + assert_eq!(got_recipient, recipient); + } + /// `combine_inputs` refuses a v6 PSBT outright. /// /// It parses the incoming bytes with `deserialize_stripped`, whose whole purpose is to accept an diff --git a/packages/wasm-utxo/src/inspect/psbt.rs b/packages/wasm-utxo/src/inspect/psbt.rs index 61fd19aca9f..129e2d05025 100644 --- a/packages/wasm-utxo/src/inspect/psbt.rs +++ b/packages/wasm-utxo/src/inspect/psbt.rs @@ -546,6 +546,10 @@ pub fn zcash_tx_to_node(parts: &ZcashTransactionParts, network: Network) -> 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); @@ -589,6 +593,132 @@ pub fn zcash_psbt_to_node(zcash_psbt: &ZcashBitGoPsbt, network: Network) -> Node 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: the shielded output value/recipient and 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` 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_output_info() { + Ok(Some((value, recipient))) => { + let mut output_node = Node::new("shielded_output", Primitive::None); + output_node.add_child(Node::new("value", Primitive::U64(value))); + output_node.add_child(Node::new( + "recipient", + Primitive::Buffer(recipient.to_vec()), + )); + node.add_child(output_node); + } + Ok(None) => { + node.add_child(Node::new( + "shielded_output", + Primitive::String("none".to_string()), + )); + } + Err(e) => { + node.add_child(Node::new("shielded_output_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, @@ -626,3 +756,169 @@ 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_psbt::ironwood_v6_tests`. +#[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], + 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 output = find(ironwood, "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"); + } +}