Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
97 changes: 51 additions & 46 deletions packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<(ParsedOutput, u64)>, 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<Vec<(ParsedOutput, u64)>, 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
Expand Down Expand Up @@ -3442,7 +3447,7 @@ impl BitGoPsbt {
paygo_pubkeys: &[secp256k1::PublicKey],
) -> Result<Vec<ParsedOutput>, 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)
Expand Down Expand Up @@ -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(
Expand Down
74 changes: 42 additions & 32 deletions packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/propkv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<String> {
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<String> {
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)]
Expand Down
Loading
Loading