From b38238495990dbcb6030089caf8f41613b9be826 Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Fri, 21 Aug 2026 18:41:31 +0530 Subject: [PATCH] feat(wasm-utxo): support multi-recipient Ironwood shielded outputs Ticket: CSHLD-1529 --- .../ZcashIronwoodBitGoPsbt.ts | 38 ++ .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 97 ++--- .../fixed_script_wallet/bitgo_psbt/propkv.rs | 74 ++-- .../bitgo_psbt/zcash_psbt.rs | 382 ++++++++++++++---- packages/wasm-utxo/src/inspect/psbt.rs | 45 ++- .../src/wasm/fixed_script_wallet/mod.rs | 40 ++ .../wasm-utxo/src/wasm/try_from_js_value.rs | 67 +++ .../wasm-utxo/src/zcash/ironwood_build.rs | 90 ++++- .../test/fixedScript/zcashIronwoodPsbt.ts | 83 ++++ 9 files changed, 719 insertions(+), 197 deletions(-) diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts index a9190aae91a..5092649841d 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts @@ -221,6 +221,44 @@ export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt { ); } + /** + * Add one or more shielded outputs (Constructor role) as a single orchard PCZT — the + * multi-recipient counterpart to {@link addShieldedOutput}. Every recipient must be passed in + * one call: only one call to `addShieldedOutput`/`addShieldedOutputs` is supported per PSBT (see + * {@link addShieldedOutput}). + * + * @param outputs - one entry per recipient; `memo` defaults per-entry to the ZIP-302 "no memo" + * encoding, exactly as {@link addShieldedOutput}'s does + * @param anchor - 32-byte Ironwood note-commitment-tree root, 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 client-managed-`ovk` caller must use these + * indices (not the position in `outputs`) when later calling + * `setIronwoodOutCiphertext`/`setIronwoodOutCiphertextForUser` for a specific recipient. + */ + addShieldedOutputs( + outputs: Array<{ + recipient: Uint8Array; + amount: bigint; + memo?: Uint8Array; + ovk?: Uint8Array; + unifiedAddress?: string; + }>, + anchor: Uint8Array, + ): number[] { + return Array.from( + this.wasm.add_ironwood_outputs( + outputs.map((o) => ({ + recipient: o.recipient, + amount: o.amount, + memo: o.memo ?? zip302NoMemo(), + ovk: o.ovk, + unifiedAddress: o.unifiedAddress, + })), + anchor, + ), + ); + } + /** * Client-managed `ovk`: re-encrypt the shielded output's `out_ciphertext` under **this wallet's** * `ovk`, derived as the ECDH agreement of `rootWalletKeys.bitgoKey()` and `userKey`. Both are root diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs index 9a54f6c6ffc..c9d8b32b724 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs @@ -2737,50 +2737,55 @@ impl BitGoPsbt { .collect() } - /// The synthesized `ParsedOutput` for this PSBT's shielded (Ironwood) output, and its value — - /// `None` if this isn't a v6 (Ironwood) PSBT, or it is but no shielded output has been added - /// yet. Shared by `parse_transaction_with_wallet_keys` (which also folds the value into - /// `miner_fee`/`spend_amount`) and `parse_outputs_with_wallet_keys` (which only needs the - /// output entry). - /// - /// The shielded output lives in a proprietary-map PCZT rather than `unsigned_tx.output`, so - /// plain transparent-output parsing never sees it; this is how callers surface it explicitly. - fn shielded_output(&self) -> Result, ParseTransactionError> { + /// The synthesized `ParsedOutput` for every one of this PSBT's shielded (Ironwood) outputs, + /// paired with its value — empty if this isn't a v6 (Ironwood) PSBT, or it is but no shielded + /// output has been added yet. Shared by `parse_transaction_with_wallet_keys` (which also folds + /// each value into `miner_fee`/`spend_amount`) and `parse_outputs_with_wallet_keys` (which only + /// needs the output entries). + /// + /// The shielded outputs live in a proprietary-map PCZT rather than `unsigned_tx.output`, so + /// plain transparent-output parsing never sees them; this is how callers surface them + /// explicitly. + fn shielded_outputs(&self) -> Result, ParseTransactionError> { let BitGoPsbt::Zcash(z, _) = self else { - return Ok(None); + return Ok(Vec::new()); }; - let Some((amount, recipient)) = z - .ironwood_shielded_output_info() - .map_err(ParseTransactionError::ShieldedOutput)? - else { - return Ok(None); - }; - // Prefer the caller's original Unified Address (if `add_ironwood_output` was given one): - // it may carry a transparent/Sapling receiver alongside the Orchard one, which a - // single-receiver reconstruction from `recipient` alone cannot recover. - let address = match propkv::get_ironwood_unified_address(&z.psbt) { - Some(ua) => ua, - None => crate::zcash::unified_address::encode_orchard_receiver( - &recipient, - self.network().to_coin_name(), - ) - .map_err(|e| ParseTransactionError::ShieldedOutput(e.to_string()))?, - }; - Ok(Some(( - ParsedOutput { - address: Some(address), - // No scriptPubKey exists for a shielded output; the raw receiver is still - // available here (not a scriptPubKey, but the same "raw output-destination - // bytes" role this field plays for transparent outputs). - script: recipient.to_vec(), - value: amount, - script_id: None, - paygo: false, - derivation_path: None, - is_shielded: Some(true), - }, - amount, - ))) + let infos = z + .ironwood_shielded_outputs_info() + .map_err(ParseTransactionError::ShieldedOutput)?; + infos + .into_iter() + .map(|(action_index, amount, recipient)| { + // Prefer the caller's original Unified Address (if `add_ironwood_outputs` was + // given one for this action): it may carry a transparent/Sapling receiver + // alongside the Orchard one, which a single-receiver reconstruction from + // `recipient` alone cannot recover. + let address = match propkv::get_ironwood_unified_address(&z.psbt, action_index) { + Some(ua) => ua, + None => crate::zcash::unified_address::encode_orchard_receiver( + &recipient, + self.network().to_coin_name(), + ) + .map_err(|e| ParseTransactionError::ShieldedOutput(e.to_string()))?, + }; + Ok(( + ParsedOutput { + address: Some(address), + // No scriptPubKey exists for a shielded output; the raw receiver is + // still available here (not a scriptPubKey, but the same "raw + // output-destination bytes" role this field plays for transparent + // outputs). + script: recipient.to_vec(), + value: amount, + script_id: None, + paygo: false, + derivation_path: None, + is_shielded: Some(true), + }, + amount, + )) + }) + .collect() } /// Calculate total input value from parsed inputs @@ -3442,7 +3447,7 @@ impl BitGoPsbt { paygo_pubkeys: &[secp256k1::PublicKey], ) -> Result, ParseTransactionError> { let mut outputs = self.parse_outputs(wallet_keys, paygo_pubkeys)?; - if let Some((output, _amount)) = self.shielded_output()? { + for (output, _amount) in self.shielded_outputs()? { outputs.push(output); } Ok(outputs) @@ -3475,10 +3480,10 @@ impl BitGoPsbt { let (mut total_output_value, mut spend_amount) = Self::sum_output_values(&psbt.unsigned_tx.output, &parsed_outputs)?; - // Fold in the shielded output, if any: it's invisible to the transparent-only parsing - // above, so without this it silently vanishes into `miner_fee` and `spend_amount` + // Fold in the shielded outputs, if any: they're invisible to the transparent-only parsing + // above, so without this they silently vanish into `miner_fee` and `spend_amount` // undercounts the send. - if let Some((output, amount)) = self.shielded_output()? { + for (output, amount) in self.shielded_outputs()? { let output_index = parsed_outputs.len(); parsed_outputs.push(output); total_output_value = total_output_value.checked_add(amount).ok_or( diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/propkv.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/propkv.rs index 4de96424809..4866f3bcfa9 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/propkv.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/propkv.rs @@ -299,15 +299,6 @@ fn get_zec_v6(psbt: &miniscript::bitcoin::psbt::Psbt, subtype: ZecV6KeySubtype) .map(|(_, v)| v.clone()) } -fn remove_zec_v6(psbt: &mut miniscript::bitcoin::psbt::Psbt, subtype: ZecV6KeySubtype) { - let key = ProprietaryKey { - prefix: BITGO_ZEC_V6.to_vec(), - subtype: subtype as u8, - key: vec![], - }; - psbt.proprietary.remove(&key); -} - fn set_zec_v6_u32( psbt: &mut miniscript::bitcoin::psbt::Psbt, subtype: ZecV6KeySubtype, @@ -393,34 +384,53 @@ pub fn get_zec_v6_params(psbt: &miniscript::bitcoin::psbt::Psbt) -> Option<(u32, Some((vgid, expiry)) } -/// Store the full Unified Address string the Ironwood shielded output was addressed to, so it -/// survives a serialize/deserialize round-trip verbatim (receivers and all) instead of being -/// rebuilt from just the raw Orchard receiver. Overwrites any existing value. -pub fn set_ironwood_unified_address(psbt: &mut miniscript::bitcoin::psbt::Psbt, ua: &str) { - set_zec_v6( - psbt, - ZecV6KeySubtype::UnifiedAddress, - ua.as_bytes().to_vec(), - ); +/// Store the full Unified Address string one Ironwood shielded output (identified by its +/// `action_index` in the orchard bundle) was addressed to, so it survives a serialize/deserialize +/// round-trip verbatim (receivers and all) instead of being rebuilt from just the raw Orchard +/// receiver. Keyed by `action_index` (as the `ProprietaryKey`'s `key` bytes) so a multi-recipient +/// bundle can store one UA per action. Overwrites any existing value for that index. +pub fn set_ironwood_unified_address( + psbt: &mut miniscript::bitcoin::psbt::Psbt, + action_index: usize, + ua: &str, +) { + let key = ProprietaryKey { + prefix: BITGO_ZEC_V6.to_vec(), + subtype: ZecV6KeySubtype::UnifiedAddress as u8, + key: (action_index as u32).to_le_bytes().to_vec(), + }; + psbt.proprietary.insert(key, ua.as_bytes().to_vec()); } -/// Fetch the Unified Address string stored by [`set_ironwood_unified_address`], if present and -/// valid UTF-8. -pub fn get_ironwood_unified_address(psbt: &miniscript::bitcoin::psbt::Psbt) -> Option { - let bytes = get_zec_v6(psbt, ZecV6KeySubtype::UnifiedAddress)?; - String::from_utf8(bytes).ok() +/// Fetch the Unified Address string stored by [`set_ironwood_unified_address`] for `action_index`, +/// if present and valid UTF-8. +pub fn get_ironwood_unified_address( + psbt: &miniscript::bitcoin::psbt::Psbt, + action_index: usize, +) -> Option { + let key = ProprietaryKey { + prefix: BITGO_ZEC_V6.to_vec(), + subtype: ZecV6KeySubtype::UnifiedAddress as u8, + key: (action_index as u32).to_le_bytes().to_vec(), + }; + let bytes = psbt.proprietary.get(&key)?; + String::from_utf8(bytes.clone()).ok() } -/// Remove the Unified Address string set by [`set_ironwood_unified_address`], if present. +/// Remove every Unified Address stored by [`set_ironwood_unified_address`], regardless of action +/// index. /// -/// Callers that build a shielded output without a `unified_address` must call this rather than -/// simply not calling [`set_ironwood_unified_address`]: `add_ironwood_output` can be called again -/// on a PSBT whose PCZT was previously extracted (see `take_ironwood_pczt`/ -/// `mark_ironwood_extracted`, which drop only the PCZT key, not this one), and without an explicit -/// removal a UA stored for an earlier shielded output would otherwise survive and be silently -/// misattributed to the new one. -pub fn remove_ironwood_unified_address(psbt: &mut miniscript::bitcoin::psbt::Psbt) { - remove_zec_v6(psbt, ZecV6KeySubtype::UnifiedAddress); +/// Callers building a fresh batch of shielded outputs must call this before storing the new +/// batch's UAs (rather than only overwriting the indices the new batch happens to use): +/// `add_ironwood_output`/`add_ironwood_outputs` can run again on a PSBT whose PCZT was previously +/// extracted (see `take_ironwood_pczt`/`mark_ironwood_extracted`, which drop only the PCZT key, not +/// these), and a new batch's action count/indices need not match the old one's — so without a +/// blanket clear, a UA stored for a since-gone action index would survive and (if the new bundle +/// happens to reuse that index) be silently misattributed to a different recipient. +pub fn clear_ironwood_unified_addresses(psbt: &mut miniscript::bitcoin::psbt::Psbt) { + psbt.proprietary.retain(|k, _| { + !(k.prefix == BITGO_ZEC_V6 && k.subtype == ZecV6KeySubtype::UnifiedAddress as u8) + }); } #[cfg(test)] 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 4e67e040bf2..5a5001714c8 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 @@ -605,6 +605,29 @@ impl ZcashBitGoPsbt { // PSBT goes to the external proof service for the Halo2 `zkproof`, and `combine_ironwood_proof` // finalizes the transparent inputs and splices in the proof + shielded bundle to produce the // broadcast-ready v6 transaction. See `crate::zcash::ironwood_build` for the PCZT role bridge. + +/// One requested Ironwood shielded output, as passed to +/// [`ZcashBitGoPsbt::add_ironwood_outputs`]. +#[derive(Clone)] +pub struct IronwoodOutputRequest { + /// 43-byte raw Orchard/Ironwood address. + pub recipient: crate::zcash::ironwood_build::OrchardAddressBytes, + /// Note value in zatoshi. + pub amount: u64, + /// Outgoing viewing key, if the output should be recoverable by the sender; `None` for a + /// keyless build. + pub ovk: Option, + /// ZIP-302 memo field. + pub memo: crate::zcash::ironwood_build::MemoBytes, + /// Full Unified Address this output was addressed to, if the caller wants it preserved. If + /// given, must be a Unified Address whose Orchard receiver is exactly `recipient` — it is + /// stored verbatim, keyed by this output's action index (see + /// [`super::propkv::set_ironwood_unified_address`]), so that output parsing can later return + /// the original multi-receiver UA rather than reconstructing a single-receiver one from + /// `recipient` alone, which drops any transparent/Sapling receiver the caller's UA carried. + pub unified_address: Option, +} + impl ZcashBitGoPsbt { /// Create an empty Zcash **v6 (Ironwood)** shielding PSBT, with the consensus branch id resolved /// from `block_height`, which must be at or after NU6.3 activation. @@ -713,40 +736,44 @@ impl ZcashBitGoPsbt { self.version_group_id == Some(crate::zcash::transaction::ZCASH_IRONWOOD_VERSION_GROUP_ID) } - /// Constructor role: build the shielded output (one Ironwood note to `recipient`) as an orchard - /// PCZT and store it in the PSBT. `recipient` is a 43-byte raw Orchard/Ironwood address, - /// `anchor` the current Ironwood note-commitment-tree root, `ovk` an optional raw outgoing - /// viewing key, `memo` the 512-byte memo field. + /// Constructor role: build the shielded output(s) as a single orchard PCZT and store it in the + /// PSBT. One Ironwood note is produced per entry of `requests`, each paired with a fabricated + /// dummy spend; `anchor` (the current Ironwood note-commitment-tree root) is shared by all of + /// them. /// /// Ordering relative to the transparent inputs/outputs does not matter — the shielded action /// data does not depend on them — but the sighash does, so all transparent I/O must be in place /// before signing. /// - /// Exactly one shielded output is supported; calling this twice is an error rather than a silent - /// overwrite of the first note (whose value the transparent side would still be funding). + /// Only one call to this (or [`Self::add_ironwood_output`]) is supported per PSBT — calling it + /// again is an error rather than a silent overwrite of the first batch (whose value the + /// transparent side would still be funding). Multiple recipients in one transaction must + /// therefore all be passed in a single `requests` slice. /// - /// `unified_address`, if supplied, must be a Unified Address whose Orchard receiver is exactly - /// `recipient` — it is stored verbatim in the proprietary map (see - /// [`super::propkv::set_ironwood_unified_address`]) so that output parsing can later return the - /// original multi-receiver UA rather than reconstructing a single-receiver one from `recipient` - /// alone, which drops any transparent/Sapling receiver the caller's UA carried. - #[allow(clippy::too_many_arguments)] - pub fn add_ironwood_output( + /// Returns the action index assigned to each request, in the same order as `requests` — the + /// orchard builder pads/reorders actions, so request order does not equal action order. A + /// client-managed-`ovk` caller must use these indices (not the request index) when later + /// calling [`Self::set_ironwood_out_ciphertext`]/[`Self::set_ironwood_out_ciphertext_for_user`] + /// for a specific recipient. + pub fn add_ironwood_outputs( &mut self, - recipient: &crate::zcash::ironwood_build::OrchardAddressBytes, - amount: u64, - ovk: Option, + requests: &[IronwoodOutputRequest], anchor: &crate::zcash::ironwood_build::AnchorBytes, - memo: &crate::zcash::ironwood_build::MemoBytes, - unified_address: Option<&str>, rng: R, - ) -> Result<(), String> { + ) -> Result, String> { if super::propkv::get_ironwood_pczt(&self.psbt).is_some() { return Err( - "an Ironwood shielded output is already present; only one is supported".to_string(), + "an Ironwood shielded output is already present; only one call is supported" + .to_string(), ); } - if let Some(ua) = unified_address { + if requests.is_empty() { + return Err("at least one Ironwood output is required".to_string()); + } + for req in requests { + let Some(ua) = &req.unified_address else { + continue; + }; let parsed = crate::zcash::unified_address::UnifiedAddress::parse( ua, self.network.to_coin_name(), @@ -759,28 +786,67 @@ impl ZcashBitGoPsbt { "unified_address has no Orchard receiver, but recipient is an Orchard address" .to_string() })?; - if orchard.as_slice() != recipient.as_slice() { + if orchard.as_slice() != req.recipient.as_slice() { return Err( "unified_address's Orchard receiver does not match recipient".to_string(), ); } } - let pczt = crate::zcash::ironwood_build::construct_shield_pczt( - recipient, amount, ovk, anchor, memo, rng, - ) - .map_err(|e| e.to_string())?; + + let specs: Vec = requests + .iter() + .map(|req| crate::zcash::ironwood_build::IronwoodOutputSpec { + recipient: req.recipient, + amount: req.amount, + ovk: req.ovk, + memo: req.memo, + }) + .collect(); + let (pczt, action_indices) = + crate::zcash::ironwood_build::construct_shield_pczt_multi(&specs, anchor, rng) + .map_err(|e| e.to_string())?; let bytes = crate::zcash::ironwood_pczt::serialize_pczt(&pczt).map_err(|e| e.to_string())?; super::propkv::set_ironwood_pczt(&mut self.psbt, bytes); - // Set-or-remove, not set-only: this can run again on a PSBT whose PCZT was previously - // extracted (mark_ironwood_extracted/take_ironwood_pczt drop only the PCZT key, not this - // one), so a `None` here must clear any UA left over from an earlier shielded output rather - // than leaving it to be silently misattributed to this one. - match unified_address { - Some(ua) => super::propkv::set_ironwood_unified_address(&mut self.psbt, ua), - None => super::propkv::remove_ironwood_unified_address(&mut self.psbt), + // Clear any UAs left over from a previous batch (this can run again on a PSBT whose PCZT + // was previously extracted — mark_ironwood_extracted/take_ironwood_pczt drop only the PCZT + // key, not these) before writing the new batch's, so a since-gone action index's stale UA + // can never be silently misattributed to a different recipient in the new bundle. + super::propkv::clear_ironwood_unified_addresses(&mut self.psbt); + for (req, &action_index) in requests.iter().zip(action_indices.iter()) { + if let Some(ua) = &req.unified_address { + super::propkv::set_ironwood_unified_address(&mut self.psbt, action_index, ua); + } } - Ok(()) + Ok(action_indices) + } + + /// Constructor role: build a single shielded output as an orchard PCZT and store it in the + /// PSBT. A thin wrapper over [`Self::add_ironwood_outputs`]; see it for the general (and + /// multi-recipient) contract, including the "only one call per PSBT" rule. + #[allow(clippy::too_many_arguments)] + pub fn add_ironwood_output( + &mut self, + recipient: &crate::zcash::ironwood_build::OrchardAddressBytes, + amount: u64, + ovk: Option, + anchor: &crate::zcash::ironwood_build::AnchorBytes, + memo: &crate::zcash::ironwood_build::MemoBytes, + unified_address: Option<&str>, + rng: R, + ) -> Result { + let action_indices = self.add_ironwood_outputs( + &[IronwoodOutputRequest { + recipient: *recipient, + amount, + ovk, + memo: *memo, + unified_address: unified_address.map(str::to_string), + }], + anchor, + rng, + )?; + Ok(action_indices[0]) } /// Deserialize the stored orchard PCZT. @@ -814,8 +880,12 @@ impl ZcashBitGoPsbt { /// [`Self::add_ironwood_output`]/[`Self::deserialize_v6`] and before /// [`Self::v6_transparent_sighash`]/[`Self::add_v6_transparent_signature`]. /// - /// `action_index` is currently always `0`: [`Self::add_ironwood_output`] permits only one - /// shielded output per transaction. + /// `action_index` is the index a shielded output landed at in the bundle — for a + /// multi-recipient build via [`Self::add_ironwood_outputs`], this is the value returned + /// alongside it by [`Self::ironwood_shielded_outputs_info`], not necessarily the position the + /// output was passed in at (see [`crate::zcash::ironwood_build::construct_shield_pczt_multi`] + /// for why actions get reordered). Call once per recipient that needs its own client-managed + /// `ovk`. pub fn set_ironwood_out_ciphertext( &mut self, action_index: usize, @@ -1021,8 +1091,9 @@ impl ZcashBitGoPsbt { Ok(true) } - /// 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 yet. + /// The action index, value (zatoshi), and raw 43-byte recipient of every shielded Ironwood + /// output added via [`Self::add_ironwood_output`]/[`Self::add_ironwood_outputs`] — empty if no + /// shielded output is present yet. /// /// 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 @@ -1033,42 +1104,51 @@ impl ZcashBitGoPsbt { /// 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`, which is guaranteed to produce - /// exactly one action. That guarantee is asserted below rather than assumed silently: if the - /// bundle type ever changes to one that pads/shuffles, a multi-action bundle's action 0 need - /// not be the real output (padding actions carry value 0 and a random recipient), so this - /// must error instead of quietly reading the wrong one. + /// Every action here is a real requested output: [`Self::add_ironwood_outputs`] builds with + /// `BundleType::UNPADDED` and zero requested spends, so (per `orchard`'s cross-address-disabled + /// pairing) each action pairs a fabricated *dummy spend* with one real output — there are no + /// dummy-*output* actions to filter out, unlike a bundle that also spends real notes. + /// + /// The returned action index is what [`super::propkv::get_ironwood_unified_address`] is keyed + /// by, for callers that want the original Unified Address (if one was stored) rather than just + /// the raw recipient. /// - /// Errors (rather than returning `None`) if the PCZT is absent because it was already + /// Errors (rather than returning empty) if the PCZT is absent because it was already /// extracted — see [`Self::require_no_shielded_output_ever_added`]. - pub fn ironwood_shielded_output_info( + pub fn ironwood_shielded_outputs_info( &self, - ) -> Result, String> { + ) -> Result< + Vec<( + usize, + u64, + crate::zcash::ironwood_build::OrchardAddressBytes, + )>, + String, + > { if self.require_no_shielded_output_ever_added()? { - return Ok(None); + return Ok(Vec::new()); } // `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))) + actions + .iter() + .enumerate() + .map(|(action_index, action)| { + 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((action_index, value, recipient)) + }) + .collect() } /// The spent-output value (zatoshi, as i64) and scriptPubKey of every transparent input, in @@ -2442,7 +2522,7 @@ mod ironwood_v6_tests { ); } - /// `unsigned_v6_txid` and `ironwood_shielded_output_info` both key off PCZT presence to decide + /// `unsigned_v6_txid` and `ironwood_shielded_outputs_info` both key off PCZT presence to decide /// whether a shielded output exists. Once extracted, the PCZT is gone but a shielded output /// *did* exist — treating that the same as "never added" would silently compute a wrong /// (transparent-only) txid, and silently drop the shielded amount back into the caller's fee @@ -2454,7 +2534,7 @@ mod ironwood_v6_tests { // Before extraction: both see the shielded output. assert!(z.unsigned_v6_txid().is_ok()); assert!( - z.ironwood_shielded_output_info().unwrap().is_some(), + !z.ironwood_shielded_outputs_info().unwrap().is_empty(), "shielded output present before extraction" ); @@ -2466,7 +2546,7 @@ mod ironwood_v6_tests { txid_err.contains("already been extracted"), "unexpected error: {txid_err}" ); - let info_err = z.ironwood_shielded_output_info().unwrap_err(); + let info_err = z.ironwood_shielded_outputs_info().unwrap_err(); assert!( info_err.contains("already been extracted"), "unexpected error: {info_err}" @@ -2475,7 +2555,7 @@ mod ironwood_v6_tests { /// The counterpart to the extraction case above: a v6 PSBT that never had a shielded output /// added at all must still work — `unsigned_v6_txid` computes the transparent-only txid, and - /// `ironwood_shielded_output_info` reports `None`, neither erroring. + /// `ironwood_shielded_outputs_info` reports an empty Vec, neither erroring. #[test] fn unsigned_v6_txid_and_shielded_output_info_handle_no_shielded_output_ever_added() { let wallet_keys = RootWalletKeys::new(get_test_wallet_keys("v6_never_shielded")); @@ -2503,7 +2583,7 @@ mod ironwood_v6_tests { }; assert!(z.unsigned_v6_txid().is_ok()); - assert!(z.ironwood_shielded_output_info().unwrap().is_none()); + assert!(z.ironwood_shielded_outputs_info().unwrap().is_empty()); } /// A signature from a key outside the input's redeem script is rejected at ingest, rather than @@ -2653,7 +2733,7 @@ mod ironwood_v6_tests { ); } - /// `ironwood_shielded_output_info` returns `None` before a shielded output has been added, and + /// `ironwood_shielded_outputs_info` returns an empty Vec 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. @@ -2684,7 +2764,7 @@ mod ironwood_v6_tests { }; // No shielded output yet (unsigned transparent skeleton). - assert!(z.ironwood_shielded_output_info().unwrap().is_none()); + assert!(z.ironwood_shielded_outputs_info().unwrap().is_empty()); let recipient = test_recipient(); z.add_ironwood_output( @@ -2697,7 +2777,9 @@ mod ironwood_v6_tests { OsRng, ) .unwrap(); - let (value, got_recipient) = z.ironwood_shielded_output_info().unwrap().unwrap(); + let outputs = z.ironwood_shielded_outputs_info().unwrap(); + assert_eq!(outputs.len(), 1); + let (_action_index, value, got_recipient) = outputs[0]; assert_eq!(value, 100_000_000); assert_eq!(got_recipient, recipient); @@ -2712,7 +2794,9 @@ mod ironwood_v6_tests { 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(); + let outputs = z.ironwood_shielded_outputs_info().unwrap(); + assert_eq!(outputs.len(), 1); + let (_action_index, value, got_recipient) = outputs[0]; assert_eq!(value, 100_000_000); assert_eq!(got_recipient, recipient); } @@ -2761,7 +2845,9 @@ mod ironwood_v6_tests { ) .unwrap(); assert_eq!( - crate::fixed_script_wallet::bitgo_psbt::propkv::get_ironwood_unified_address(&z.psbt), + crate::fixed_script_wallet::bitgo_psbt::propkv::get_ironwood_unified_address( + &z.psbt, 0 + ), Some(ua.clone()) ); @@ -2770,7 +2856,8 @@ mod ironwood_v6_tests { ZcashBitGoPsbt::deserialize_v6(&z.serialize_v6(), Network::ZcashTestnet).unwrap(); assert_eq!( crate::fixed_script_wallet::bitgo_psbt::propkv::get_ironwood_unified_address( - &round.psbt + &round.psbt, + 0 ), Some(ua) ); @@ -2844,7 +2931,9 @@ mod ironwood_v6_tests { ) .unwrap(); assert_eq!( - crate::fixed_script_wallet::bitgo_psbt::propkv::get_ironwood_unified_address(&z.psbt), + crate::fixed_script_wallet::bitgo_psbt::propkv::get_ironwood_unified_address( + &z.psbt, 0 + ), Some(ua_a.clone()) ); @@ -2870,11 +2959,140 @@ mod ironwood_v6_tests { // The stale UA from recipient_a must be gone, not silently attributed to recipient_b. assert_eq!( - crate::fixed_script_wallet::bitgo_psbt::propkv::get_ironwood_unified_address(&z.psbt), + crate::fixed_script_wallet::bitgo_psbt::propkv::get_ironwood_unified_address( + &z.psbt, 0 + ), None ); } + /// `add_ironwood_outputs` builds a single bundle with one action per recipient — each carrying + /// its own value, recipient, and (if supplied) stored Unified Address — and that survives a + /// serialize/deserialize round-trip. This is the actual multi-recipient path; + /// `add_ironwood_output` is just its one-recipient special case. + #[test] + fn add_ironwood_outputs_supports_multiple_recipients() { + let wallet_keys = RootWalletKeys::new(get_test_wallet_keys("v6_multi_recipient")); + 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([0x66u8; 32]), + 0, + 300_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"); + }; + + let recipient_a = test_recipient(); + let recipient_b = { + let sk = Option::::from(SpendingKey::from_bytes([11u8; 32])).unwrap(); + FullViewingKey::from(&sk) + .address_at(0u32, Scope::External) + .to_raw_address_bytes() + }; + let ua_a = + crate::zcash::unified_address::encode_orchard_receiver(&recipient_a, "tzec").unwrap(); + + z.add_ironwood_outputs( + &[ + IronwoodOutputRequest { + recipient: recipient_a, + amount: 100_000_000, + ovk: None, + memo: [0u8; 512], + unified_address: Some(ua_a.clone()), + }, + IronwoodOutputRequest { + recipient: recipient_b, + amount: 100_000_000, + ovk: None, + memo: [0u8; 512], + unified_address: None, + }, + ], + &Anchor::empty_tree().to_bytes(), + OsRng, + ) + .unwrap(); + + // Both recipients are present, each with its own value/recipient, and each's stored UA + // (or lack of one) tracks its own action index — not just index 0. + let outputs = z.ironwood_shielded_outputs_info().unwrap(); + assert_eq!(outputs.len(), 2); + let mut by_recipient: std::collections::HashMap<_, _> = outputs + .iter() + .map(|&(action_index, value, recipient)| (recipient, (action_index, value))) + .collect(); + let (index_a, value_a) = by_recipient + .remove(&recipient_a) + .expect("recipient_a present"); + let (index_b, value_b) = by_recipient + .remove(&recipient_b) + .expect("recipient_b present"); + assert_eq!(value_a, 100_000_000); + assert_eq!(value_b, 100_000_000); + assert_ne!(index_a, index_b); + assert_eq!( + crate::fixed_script_wallet::bitgo_psbt::propkv::get_ironwood_unified_address( + &z.psbt, index_a + ), + Some(ua_a.clone()) + ); + assert_eq!( + crate::fixed_script_wallet::bitgo_psbt::propkv::get_ironwood_unified_address( + &z.psbt, index_b + ), + None + ); + + // Survives serialize/deserialize. + let round = + ZcashBitGoPsbt::deserialize_v6(&z.serialize_v6(), Network::ZcashTestnet).unwrap(); + let round_outputs = round.ironwood_shielded_outputs_info().unwrap(); + assert_eq!(round_outputs.len(), 2); + assert_eq!( + crate::fixed_script_wallet::bitgo_psbt::propkv::get_ironwood_unified_address( + &round.psbt, + index_a + ), + Some(ua_a) + ); + } + + /// A second call to `add_ironwood_outputs`/`add_ironwood_output` is rejected outright, even for + /// a multi-recipient batch: every recipient must go in the single call. + #[test] + fn add_ironwood_outputs_twice_is_rejected() { + let mut z = build_shield_psbt("v6_multi_twice"); + let err = z + .add_ironwood_outputs( + &[IronwoodOutputRequest { + recipient: test_recipient(), + amount: 1, + ovk: None, + memo: [0u8; 512], + unified_address: None, + }], + &Anchor::empty_tree().to_bytes(), + OsRng, + ) + .unwrap_err(); + assert!(err.contains("already present"), "unexpected error: {err}"); + } + /// `combine_inputs` refuses a v6 PSBT outright. /// /// It parses the incoming bytes with `deserialize_stripped`, whose whole purpose is to accept an @@ -2999,7 +3217,7 @@ mod ironwood_v6_tests { let round = ZcashBitGoPsbt::deserialize_v6_pre_shield(&bytes, Network::ZcashTestnet) .expect("pre-shield deserialize accepts a missing PCZT"); assert!(round.is_ironwood_v6()); - assert!(round.ironwood_shielded_output_info().unwrap().is_none()); + assert!(round.ironwood_shielded_outputs_info().unwrap().is_empty()); // Still rejects a v6 PSBT missing its branch id — that check isn't gated by `require_pczt`. let mut psbt_no_branch = z.psbt.clone(); @@ -3031,7 +3249,7 @@ mod ironwood_v6_tests { let mut z = ZcashBitGoPsbt::new_v6_bare(Network::ZcashTestnet, consensus_branch_id, None, None); assert!(z.is_ironwood_v6()); - assert!(z.ironwood_shielded_output_info().unwrap().is_none()); + assert!(z.ironwood_shielded_outputs_info().unwrap().is_empty()); // Round-trip through serialize/deserialize before adding the transparent input, as the // CLI does between each subcommand. @@ -3073,7 +3291,7 @@ mod ironwood_v6_tests { // Final round-trip: now that the PCZT is present, plain `deserialize_v6` accepts it too. let bytes = z.serialize().unwrap(); let round = ZcashBitGoPsbt::deserialize_v6(&bytes, Network::ZcashTestnet).unwrap(); - assert!(round.ironwood_shielded_output_info().unwrap().is_some()); + assert!(!round.ironwood_shielded_outputs_info().unwrap().is_empty()); assert_eq!( round.unsigned_v6_txid().unwrap(), z.unsigned_v6_txid().unwrap() diff --git a/packages/wasm-utxo/src/inspect/psbt.rs b/packages/wasm-utxo/src/inspect/psbt.rs index 5c8eaabc699..e13a2ca3a66 100644 --- a/packages/wasm-utxo/src/inspect/psbt.rs +++ b/packages/wasm-utxo/src/inspect/psbt.rs @@ -664,30 +664,38 @@ fn transparent_signing_state(inputs: &[crate::bitcoin::psbt::Input]) -> &'static "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`). +/// The `ironwood` node: every shielded output's value/recipient (one per action) 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_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) => { + match zcash_psbt.ironwood_shielded_outputs_info() { + Ok(outputs) if outputs.is_empty() => { node.add_child(Node::new( - "shielded_output", + "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))); } @@ -873,7 +881,8 @@ mod ironwood_v6_tests { 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 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), 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 4acd31f5f31..9a3ab0ee4f3 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -572,6 +572,46 @@ impl BitGoPsbt { 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 client-managed-`ovk` caller must use these + /// indices (not the position in `outputs`) when later calling `set_ironwood_out_ciphertext` + /// for a specific recipient. + pub fn add_ironwood_outputs( + &mut self, + outputs: JsValue, + anchor: &[u8], + ) -> Result, WasmUtxoError> { + use crate::fixed_script_wallet::bitgo_psbt::zcash_psbt::IronwoodOutputRequest; + use crate::zcash::ironwood_build::{AnchorBytes, ANCHOR_SIZE}; + + 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)) } 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 067343fd4d5..3203f06298b 100644 --- a/packages/wasm-utxo/src/wasm/try_from_js_value.rs +++ b/packages/wasm-utxo/src/wasm/try_from_js_value.rs @@ -291,3 +291,70 @@ 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 { + use crate::zcash::ironwood_build::{MemoBytes, OrchardAddressBytes, OvkBytes}; + + 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, + }, + ) + } +} diff --git a/packages/wasm-utxo/src/zcash/ironwood_build.rs b/packages/wasm-utxo/src/zcash/ironwood_build.rs index d3c2b2672c6..5146141d424 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_build.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_build.rs @@ -138,39 +138,91 @@ impl core::fmt::Display for IronwoodBuildError { crate::impl_wasm_error_code!(IronwoodBuildError); -/// Constructor: build a transparent → Ironwood shielding bundle as an orchard PCZT. +/// One requested Ironwood shielded output, as passed to [`construct_shield_pczt_multi`]. +#[derive(Clone, Copy)] +pub struct IronwoodOutputSpec { + /// 43-byte raw Orchard/Ironwood address. + pub recipient: OrchardAddressBytes, + /// Note value in zatoshi. + pub amount: u64, + /// Outgoing viewing key, if the output should be recoverable by the sender; `None` for a + /// keyless build. + pub ovk: Option, + /// ZIP-302 memo field. + pub memo: MemoBytes, +} + +/// Constructor: build a transparent → Ironwood shielding bundle with one or more outputs, as an +/// orchard PCZT. +/// +/// Each entry in `outputs` becomes one output note, each paired with a fabricated dummy spend +/// (`BundleType::UNPADDED` ⇒ exactly `outputs.len()` actions, no additional padding). `anchor` is +/// the current Ironwood note-commitment-tree root, shared by every action. `rng` must be a CSPRNG — +/// it seeds the note randomness (`rseed`, `rcv`) that fixes the action data. /// -/// Produces a single output note of `amount` zatoshi to `recipient` (a 43-byte raw Orchard/Ironwood -/// address), paired with a dummy spend (`BundleType::UNPADDED` ⇒ exactly one action). `anchor` is -/// the current Ironwood note-commitment-tree root. `ovk`, if given, is the raw outgoing viewing key -/// used to make the output recoverable by the sender; pass `None` for a keyless build. `rng` must be -/// a CSPRNG — it seeds the note randomness (`rseed`, `rcv`) that fixes the action data. +/// Actions are randomized (reordered) relative to `outputs`' order (see +/// `orchard::builder::BundleMetadata`), so the returned `Vec` gives, for each `outputs[i]`, +/// the action index it landed at in the bundle — callers that need to associate per-output data (a +/// stored Unified Address, a per-output `ovk` re-encryption) with the right action must go through +/// this mapping rather than assuming index `i`. /// /// The returned PCZT carries no signatures or proof yet; run [`finalize_shield_io`] once the sighash /// is known, then hand it to the prover and [`combine`]. -pub fn construct_shield_pczt( - recipient: &OrchardAddressBytes, - amount: u64, - ovk: Option, +pub fn construct_shield_pczt_multi( + outputs: &[IronwoodOutputSpec], anchor: &AnchorBytes, - memo: &MemoBytes, rng: R, -) -> Result { - let recipient = Option::from(Address::from_raw_address_bytes(recipient)) - .ok_or(IronwoodBuildError::BadRecipient)?; +) -> Result<(PcztBundle, Vec), IronwoodBuildError> { + if outputs.is_empty() { + return Err(IronwoodBuildError::EmptyBundle); + } let anchor = Option::from(Anchor::from_bytes(*anchor)).ok_or(IronwoodBuildError::BadAnchor)?; - let ovk = ovk.map(OutgoingViewingKey::from); let bundle_version = BundleVersion::ironwood_v3(); let flags = bundle_version.default_flags(); let mut builder = Builder::new(BundleType::UNPADDED, bundle_version, flags, anchor) .map_err(|e| IronwoodBuildError::Builder(e.to_string()))?; - builder - .add_output(ovk, recipient, NoteValue::from_raw(amount), *memo) - .map_err(|e| IronwoodBuildError::Output(e.to_string()))?; - let (bundle, _meta) = builder + for spec in outputs { + let recipient = Option::from(Address::from_raw_address_bytes(&spec.recipient)) + .ok_or(IronwoodBuildError::BadRecipient)?; + let ovk = spec.ovk.map(OutgoingViewingKey::from); + builder + .add_output(ovk, recipient, NoteValue::from_raw(spec.amount), spec.memo) + .map_err(|e| IronwoodBuildError::Output(e.to_string()))?; + } + let (bundle, meta) = builder .build_for_pczt(rng) .map_err(|e| IronwoodBuildError::Builder(e.to_string()))?; + let action_indices = (0..outputs.len()) + .map(|i| { + meta.output_action_index(i) + .ok_or(IronwoodBuildError::ActionIndexOutOfRange) + }) + .collect::, _>>()?; + Ok((bundle, action_indices)) +} + +/// Constructor: build a transparent → Ironwood shielding bundle as an orchard PCZT, for a single +/// output. A thin wrapper over [`construct_shield_pczt_multi`]; see it for the general (and +/// multi-output) contract. +pub fn construct_shield_pczt( + recipient: &OrchardAddressBytes, + amount: u64, + ovk: Option, + anchor: &AnchorBytes, + memo: &MemoBytes, + rng: R, +) -> Result { + let (bundle, _action_indices) = construct_shield_pczt_multi( + &[IronwoodOutputSpec { + recipient: *recipient, + amount, + ovk, + memo: *memo, + }], + anchor, + rng, + )?; Ok(bundle) } diff --git a/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts b/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts index 3dfb8dd3df1..4da5a1a17ad 100644 --- a/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts +++ b/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts @@ -424,6 +424,89 @@ describe("ZcashIronwoodBitGoPsbt v6 (Ironwood)", function () { ); }); + describe("addShieldedOutputs (multi-recipient)", function () { + // A second, distinct Orchard receiver — reusing the multi-receiver UA fixture's Orchard + // component, which is unrelated to RECIPIENT. + const RECIPIENT_2 = Buffer.from( + ZcashUnifiedAddress.parse(MULTI_RECEIVER_UA, "zcashTest").orchardReceiver ?? [], + ); + + function emptyPsbtWithoutShieldedOutput(): ZcashIronwoodBitGoPsbt { + const psbt = ZcashIronwoodBitGoPsbt.createEmpty("zcashTest", walletKeys, { + blockHeight: NU6_3_TESTNET_HEIGHT, + }); + psbt.addWalletInput({ txid: "11".repeat(32), vout: 0, value: 300_000_000n }, walletKeys, { + scriptId: SCRIPT_ID, + signPath: { signer: "user", cosigner: "bitgo" }, + }); + psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 99_900_000n }); + return psbt; + } + + it("adds multiple recipients in a single call, each with its own value and UA", function () { + const psbt = emptyPsbtWithoutShieldedOutput(); + psbt.addShieldedOutputs( + [ + { recipient: RECIPIENT, amount: 100_000_000n, unifiedAddress: undefined }, + { recipient: RECIPIENT_2, amount: 50_000_000n, unifiedAddress: MULTI_RECEIVER_UA }, + ], + new Uint8Array(32), + ); + + const round = ZcashIronwoodBitGoPsbt.fromBytes(psbt.serialize(), "zcashTest"); + const outputs = round.parseOutputsWithWalletKeys(walletKeys); + const shielded = outputs.filter((o) => o.isShielded); + assert.strictEqual(shielded.length, 2); + + const byScript = new Map(shielded.map((o) => [Buffer.from(o.script).toString("hex"), o])); + const outA = byScript.get(RECIPIENT.toString("hex")); + const outB = byScript.get(RECIPIENT_2.toString("hex")); + assert.ok(outA, "recipient A present"); + assert.ok(outB, "recipient B present"); + assert.strictEqual(outA.value, 100_000_000n); + assert.strictEqual(outB.value, 50_000_000n); + + // Only recipient B's UA was stored; recipient A falls back to a re-encoded single-receiver UA. + assert.strictEqual(outB.address, MULTI_RECEIVER_UA); + assert.notStrictEqual(outA.address, MULTI_RECEIVER_UA); + assert.ok(outA.address); + const recoveredA = ZcashUnifiedAddress.parse(outA.address, "zcashTest"); + assert.deepStrictEqual(Buffer.from(recoveredA.orchardReceiver ?? []), RECIPIENT); + }); + + it("folds every recipient's value into spendAmount/minerFee via parseTransactionWithWalletKeys", function () { + const psbt = emptyPsbtWithoutShieldedOutput(); + psbt.addShieldedOutputs( + [ + { recipient: RECIPIENT, amount: 100_000_000n, unifiedAddress: undefined }, + { recipient: RECIPIENT_2, amount: 50_000_000n, unifiedAddress: undefined }, + ], + new Uint8Array(32), + ); + + const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, { + replayProtection: { publicKeys: [] }, + }); + const shielded = parsed.outputs.filter((o) => o.isShielded); + assert.strictEqual(shielded.length, 2); + // 300_000_000 in - (99_900_000 transparent change + 150_000_000 shielded) = 50_100_000 fee. + assert.strictEqual(parsed.minerFee, 50_100_000n); + assert.strictEqual(parsed.spendAmount, 150_000_000n); + }); + + it("rejects a second call, even to add more recipients", function () { + const psbt = buildShieldPsbt(); + assert.throws( + () => + psbt.addShieldedOutputs( + [{ recipient: RECIPIENT_2, amount: 1n, unifiedAddress: undefined }], + new Uint8Array(32), + ), + /already present/, + ); + }); + }); + describe("combineProof", function () { // The Rust `combine_ironwood_proof` consumes `self`, so the wasm binding clones and only calls // `mark_ironwood_extracted()` after the clone has actually produced a transaction. That ordering