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
16 changes: 14 additions & 2 deletions packages/wasm-utxo/js/address.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,20 @@ import type { CoinName } from "./coinName.js";
*/
export type AddressFormat = "default" | "cashaddr";

export function toOutputScriptWithCoin(address: string, coin: CoinName): Uint8Array {
return AddressNamespace.to_output_script_with_coin(address, coin);
/**
* @param canBeShieldedOutput - When set and `address` is a ZIP-316 unified address carrying an
* Orchard/Ironwood receiver, returns that raw 43-byte receiver instead of a transparent
* scriptPubKey (there is no scriptPubKey for a shielded output). `address` is only even
* attempted as a unified address when this is set. If `address` looks like a unified address for
* this coin's network but is malformed, or has no Orchard/Ironwood receiver (e.g. Sapling-only),
* this throws rather than falling back to the transparent path.
*/
export function toOutputScriptWithCoin(
address: string,
coin: CoinName,
canBeShieldedOutput?: boolean,
): Uint8Array {
return AddressNamespace.to_output_script_with_coin(address, coin, canBeShieldedOutput);
}

export function fromOutputScriptWithCoin(
Expand Down
10 changes: 10 additions & 0 deletions packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ export type ParsedOutput = {
paygo: boolean;
/** Full BIP32 derivation path from the wallet xpub (e.g. "0/1"). Null for external outputs. */
derivationPath: string | null;
/**
* True for a shielded (Orchard/Ironwood) output. Such an output has no `unsigned_tx` entry of
* its own — it lives in the PSBT's proprietary-map PCZT, read from its plaintext (not
* encrypted/decrypted) recipient field. `address` is a single-receiver ZIP-316 unified address
* (`u1...`/`utest1...`) encoding that receiver — a real, usable Zcash address, though not
* necessarily byte-identical to whatever multi-receiver UA the sender originally pasted in (a
* UA with a transparent/Sapling receiver too would round-trip to a different string carrying
* only the Orchard one). `script` holds the same receiver as raw 43 bytes.
*/
isShielded: boolean;
};

export type ParsedTransaction = {
Expand Down
16 changes: 16 additions & 0 deletions packages/wasm-utxo/js/fixedScriptWallet/ZcashUnifiedAddress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,22 @@ export class ZcashUnifiedAddress {
return this._wasm.contains(candidate);
}

/**
* Encode a raw 43-byte Orchard/Ironwood receiver as a single-receiver Unified Address.
*
* Deliberately narrower than a general UA encoder: this always produces exactly one receiver,
* so it can't reproduce a multi-receiver UA (transparent + Sapling + Orchard) a sender might
* have originally pasted in — it only gives back *a* valid, usable address for the
* Orchard/Ironwood receiver itself.
*
* @param receiver - 43-byte raw Orchard/Ironwood receiver
* @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec")
* @throws If `receiver` is not 43 bytes, or `network` is unrecognized
*/
static encodeOrchardReceiver(receiver: Uint8Array, network: ZcashNetworkName): string {
return WasmZcashUnifiedAddress.encodeOrchardReceiver(receiver, network);
}

/** @internal */
get wasm(): WasmZcashUnifiedAddress {
return this._wasm;
Expand Down
112 changes: 112 additions & 0 deletions packages/wasm-utxo/src/address/networks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,43 @@ pub fn to_output_script_with_coin(address: &str, coin: &str) -> Result<ScriptBuf
to_output_script_with_network(address, network)
}

/// Like [`to_output_script_with_coin`], but when `can_be_shielded_output` is set and `address` is
/// a ZIP-316 unified address for `coin`'s network, returns the UA's raw 43-byte Orchard/Ironwood
/// receiver instead of a transparent scriptPubKey — there is no scriptPubKey for a shielded
/// output, so this can't return a `ScriptBuf` uniformly and returns raw bytes instead.
///
/// `address` is only even attempted as a UA when `can_be_shielded_output` is set: a transparent
/// address is never itself a valid UA (a UA HRP can't collide with a transparent address's own
/// encoding), so this flag exists purely to make "the caller is prepared to receive an Ironwood
/// receiver instead of a scriptPubKey" explicit rather than inferred from the address string.
///
/// If `address` merely *looks* like a UA for this network (right Bech32m HRP) but is malformed,
/// or is well-formed but has no Orchard/Ironwood receiver (e.g. Sapling-only), this errors rather
/// than silently falling back to the transparent path — a UA that can't yield the shielded
/// receiver the caller asked for is a caller bug, not an alternate valid address.
pub fn to_output_script_or_shielded_receiver_with_coin(
address: &str,
coin: &str,
can_be_shielded_output: bool,
) -> Result<Vec<u8>> {
if can_be_shielded_output
&& crate::zcash::unified_address::looks_like_unified_for_network(address, coin)
{
let ua = crate::zcash::unified_address::UnifiedAddress::parse(address, coin)
.map_err(|e| AddressError::InvalidAddress(e.to_string()))?;
return match ua
.orchard_receiver()
.map_err(|e| AddressError::InvalidAddress(e.to_string()))?
{
Some(receiver) => Ok(receiver),
None => Err(AddressError::InvalidAddress(format!(
"unified address has no Orchard/Ironwood receiver: {address}"
))),
};
}
to_output_script_with_coin(address, coin).map(|script| script.to_bytes().to_vec())
}

/// Convert an output script to an address string using a BitGo coin name.
/// The coin name is first converted to a Network using `Network::from_coin_name()`.
pub fn from_output_script_with_coin(script: &Script, coin: &str) -> Result<String> {
Expand Down Expand Up @@ -446,6 +483,81 @@ mod tests {
assert!(result.is_err());
}

mod shielded_output {
use super::*;

fn ua_fixtures() -> serde_json::Value {
let s = crate::fixed_script_wallet::test_utils::fixtures::load_fixture(
"zcash/unified_address.json",
)
.expect("load unified_address.json");
serde_json::from_str(&s).expect("parse unified_address.json")
}

fn fx(v: &serde_json::Value, group: &str, key: &str) -> String {
v[group][key]
.as_str()
.unwrap_or_else(|| panic!("missing fixture field {}.{}", group, key))
.to_string()
}

#[test]
fn returns_the_orchard_receiver_for_a_unified_address_when_set() {
let f = ua_fixtures();
let ua = fx(&f, "zip316Mainnet", "unified");
let expected = hex::decode(fx(&f, "zip316Mainnet", "orchardReceiverHex")).unwrap();

let receiver =
to_output_script_or_shielded_receiver_with_coin(&ua, "zec", true).unwrap();
assert_eq!(receiver, expected);
assert_eq!(receiver.len(), 43);
}

#[test]
fn never_even_attempts_ua_parsing_when_unset() {
// A unified address is never itself a valid transparent address, so without the flag
// this must fail exactly like it did before this feature existed — not succeed by
// accidentally matching some transparent codec.
let ua = fx(&ua_fixtures(), "zip316Mainnet", "unified");
assert!(to_output_script_or_shielded_receiver_with_coin(&ua, "zec", false).is_err());
}

#[test]
fn falls_through_to_the_transparent_path_for_an_ordinary_address_even_when_set() {
let f = ua_fixtures();
let addr = fx(&f, "testnetWallet", "transparentAddress");
let expected_hash = hex::decode(fx(&f, "testnetWallet", "transparentPubkeyHashHex"))
.unwrap()
.try_into()
.unwrap();
let expected =
ScriptBuf::new_p2pkh(&PubkeyHash::from_byte_array(expected_hash)).to_bytes();

let script =
to_output_script_or_shielded_receiver_with_coin(&addr, "tzec", true).unwrap();
assert_eq!(script, expected);
}

#[test]
fn errors_on_a_wrong_network_unified_address_rather_than_succeeding() {
// The mainnet UA's HRP ("u") doesn't match testnet's ("utest"), so
// `looks_like_unified_for_network` itself returns false — this never reaches
// `UnifiedAddress::parse`'s own (separately tested, in unified_address.rs)
// `WrongHrp` check; it falls through to the transparent path instead, which fails for
// an unrelated reason (a UA string never decodes as a transparent address). Assert on
// that specific failure rather than a bare `is_err()`, so this pins down which path
// actually rejected it — a bare `is_err()` would still pass even if the network check
// were silently removed entirely.
let ua = fx(&ua_fixtures(), "zip316Mainnet", "unified");
let err =
to_output_script_or_shielded_receiver_with_coin(&ua, "tzec", true).unwrap_err();
assert!(
err.to_string().contains("Could not decode address"),
"expected the transparent-decode fallback to fail; got: {err}"
);
}
}

#[test]
fn test_base58_bitcoin_cash() {
// Bitcoin Cash should prefer base58 format for encoding
Expand Down
89 changes: 82 additions & 7 deletions packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ pub enum ParseTransactionError {
SpendAmountOverflow { index: usize },
/// Fee calculation error (outputs exceed inputs)
FeeCalculation,
/// Failed to read the shielded (Ironwood) output's value
ShieldedOutput(String),
}

impl std::fmt::Display for ParseTransactionError {
Expand All @@ -181,6 +183,9 @@ impl std::fmt::Display for ParseTransactionError {
ParseTransactionError::FeeCalculation => {
write!(f, "Fee calculation error: outputs exceed inputs")
}
ParseTransactionError::ShieldedOutput(error) => {
write!(f, "Shielded output: {}", error)
}
}
}
}
Expand Down Expand Up @@ -1839,10 +1844,18 @@ impl BitGoPsbt {
}
BitGoPsbt::Zcash(zcash_psbt, _) => {
use miniscript::bitcoin::hashes::{sha256d, Hash};
// Compute txid from full Zcash transaction bytes
let txid_bytes = zcash_psbt
.compute_txid()
.expect("Failed to compute Zcash txid");
// A v6 (Ironwood) PSBT's txid is the ZIP-244 digest over the transparent skeleton
// plus shielded action data — the v4/Sapling path below builds an invalid
// transaction for it (wrong wire format entirely), so it must not be used here.
let txid_bytes = if zcash_psbt.is_ironwood_v6() {
zcash_psbt
.unsigned_v6_txid()
.expect("Failed to compute v6 (Ironwood) txid")
} else {
zcash_psbt
.compute_txid()
.expect("Failed to compute Zcash txid")
};
let hash = sha256d::Hash::from_byte_array(txid_bytes);
Txid::from_raw_hash(hash)
}
Expand Down Expand Up @@ -2724,6 +2737,46 @@ 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> {
let BitGoPsbt::Zcash(z, _) = self else {
return Ok(None);
};
let Some((amount, recipient)) = z
.ironwood_shielded_output_info()
.map_err(ParseTransactionError::ShieldedOutput)?
else {
return Ok(None);
};
let address = 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: true,
},
amount,
)))
}

/// Calculate total input value from parsed inputs
///
/// # Returns
Expand Down Expand Up @@ -3382,7 +3435,11 @@ impl BitGoPsbt {
wallet_keys: &crate::fixed_script_wallet::RootWalletKeys,
paygo_pubkeys: &[secp256k1::PublicKey],
) -> Result<Vec<ParsedOutput>, ParseTransactionError> {
self.parse_outputs(wallet_keys, paygo_pubkeys)
let mut outputs = self.parse_outputs(wallet_keys, paygo_pubkeys)?;
if let Some((output, _amount)) = self.shielded_output()? {
outputs.push(output);
}
Ok(outputs)
}

/// Parse transaction with wallet keys to identify wallet inputs/outputs and calculate metrics
Expand All @@ -3405,13 +3462,31 @@ impl BitGoPsbt {

// Parse inputs and outputs
let parsed_inputs = self.parse_inputs(wallet_keys, replay_protection)?;
let parsed_outputs = self.parse_outputs(wallet_keys, paygo_pubkeys)?;
let mut parsed_outputs = self.parse_outputs(wallet_keys, paygo_pubkeys)?;

// Calculate totals
let total_input_value = Self::sum_input_values(&parsed_inputs)?;
let (total_output_value, spend_amount) =
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`
// undercounts the send.
if let Some((output, amount)) = self.shielded_output()? {
let output_index = parsed_outputs.len();
parsed_outputs.push(output);
total_output_value = total_output_value.checked_add(amount).ok_or(
ParseTransactionError::OutputValueOverflow {
index: output_index,
},
)?;
spend_amount = spend_amount.checked_add(amount).ok_or(
ParseTransactionError::SpendAmountOverflow {
index: output_index,
},
)?;
}

// Calculate miner fee
let miner_fee = total_input_value
.checked_sub(total_output_value)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,10 @@ pub enum ZecV6KeySubtype {
IronwoodPczt = 0x01,
VersionGroupId = 0x02,
ExpiryHeight = 0x03,
/// Marker set once [`take_ironwood_pczt`] has actually dropped a PCZT (i.e. extraction
/// happened, not just "no shielded output was ever added"). Persists even though the PCZT
/// itself is gone, so a later read can tell the two "no PCZT" states apart.
IronwoodExtracted = 0x04,
}

fn set_zec_v6(
Expand Down Expand Up @@ -334,7 +338,24 @@ pub fn take_ironwood_pczt(psbt: &mut miniscript::bitcoin::psbt::Psbt) -> bool {
subtype: ZecV6KeySubtype::IronwoodPczt as u8,
key: vec![],
};
psbt.proprietary.remove(&key).is_some()
let took = psbt.proprietary.remove(&key).is_some();
if took {
set_ironwood_extracted(psbt);
}
took
}

/// Mark the PSBT as having had its Ironwood PCZT extracted (see [`take_ironwood_pczt`]).
/// Persists across `serialize`/`deserialize`, unlike the PCZT itself, so a later "was a shielded
/// output added and then extracted, or never added at all?" check can tell the two apart even
/// though both look identical from PCZT-presence alone.
fn set_ironwood_extracted(psbt: &mut miniscript::bitcoin::psbt::Psbt) {
set_zec_v6(psbt, ZecV6KeySubtype::IronwoodExtracted, vec![]);
}

/// Whether the PSBT's Ironwood PCZT has been extracted (dropped by [`take_ironwood_pczt`]).
pub fn is_ironwood_extracted(psbt: &miniscript::bitcoin::psbt::Psbt) -> bool {
get_zec_v6(psbt, ZecV6KeySubtype::IronwoodExtracted).is_some()
}

/// Store the Zcash v6 (Ironwood) header params — `version_group_id` and `expiry_height` — under the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ pub struct ParsedOutput {
/// Full BIP32 derivation path from the wallet xpub (e.g. `[chain, index]`).
/// `None` for outputs that do not belong to this wallet.
pub derivation_path: Option<DerivationPath>,
/// Whether this output is a shielded (Orchard/Ironwood) output rather than a transparent one.
/// Always `false` for outputs parsed from `tx_output`/`psbt_output` — set by the caller when
/// synthesizing a `ParsedOutput` for the shielded side of a v6 (Ironwood) transaction.
pub is_shielded: bool,
}

impl ParsedOutput {
Expand Down Expand Up @@ -59,6 +63,7 @@ impl ParsedOutput {
script_id,
paygo,
derivation_path,
is_shielded: false,
})
}

Expand Down
Loading
Loading