Skip to content
Merged
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
7 changes: 7 additions & 0 deletions packages/wasm-utxo/docs/zcash-v6-transaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ receiver), and its value becomes `valueBalanceIronwood` rather than a transparen
`TxOut` — an indexer-side change in `buildTransaction` that reuses the existing
unspent-selection / fee / change / response code unchanged.

The custodial **MPC** counterpart is implemented separately, keyless end to end:
`ZcashIronwoodBitGoPsbt.createEmptyKeyless(network, { blockHeight, expiryHeight?, lockTime? })`
builds a v6 PSBT **without any `walletKeys`** (no global xpubs — a custodial MPC shielded wallet
has no fixed-script xpub triple), `addShieldedSpends` is already keyless, and
`parseShieldedTransaction()` returns the keyless parse `{shieldedSpends, outputs, valueBalance,
fee}` for a caller that never holds the keys.

## Capturing golden fixtures

Golden fixtures live in `test/fixtures/zcash/` as a `(v6_<name>_rawtx.hex,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,21 @@ export type CreateEmptyIronwoodWithConsensusBranchIdOptions = {
expiryHeight?: number;
};

/**
* Options for creating an empty Zcash v6 (Ironwood) shielding PSBT **without** wallet keys —
* the MPC-shielded build, where no fixed-script xpub triple exists to stamp.
*
* Shape matches {@link CreateEmptyIronwoodOptions} minus `walletKeys`.
*/
export type CreateEmptyKeylessOptions = {
/** Block height to determine the consensus branch ID automatically (at/after NU6.3 activation) */
blockHeight: number;
/** Zcash transaction expiry height */
expiryHeight?: number;
/** Lock time (default: 0) */
lockTime?: number;
};

/**
* A fresh ZIP-302 "no memo" memo field: the `0xf6` marker byte followed by 511 zero bytes. The
* default for {@link ZcashIronwoodBitGoPsbt.addShieldedOutput}'s `memo` option.
Expand Down Expand Up @@ -102,6 +117,31 @@ export type ZcashIronwoodParsedTransaction =
shieldedSpends: ZcashShieldedSpend[];
});

/**
* The keyless parse of a v6 (Ironwood) transaction — the MPC-shielded shape, and the
* wallet-keys-free counterpart to {@link ZcashIronwoodParsedTransaction}. The key-free
* verifier runs first: a tampered bundle throws with the failing check's `err.code`.
*/
export type ZcashShieldedParsedTransaction = {
/** The verified shielded spends, in action order. */
shieldedSpends: ZcashShieldedSpend[];
/**
* Every output: transparent first (tx output order), then the shielded ones (action
* order) — the same order {@link parseOutputsWithWalletKeys} reports. A transparent
* output's address is its stored `unifiedAddress` (when `addTransparentOutput` was given
* one) or the address decoded from the scriptPubKey; the wallet-ownership fields
* (`scriptId`, `derivationPath`) are always null.
*/
outputs: ZcashIronwoodParsedOutput[];
/** The orchard bundle's value balance (Σv_spend − Σv_out); `0` without a bundle. */
valueBalance: bigint;
/**
* Fee: total input value (transparent inputs + shielded spends) − total output value.
* Transparent input values come from the PSBT's utxo fields as-is.
*/
fee: bigint;
};

/** The parse fields shared by both `inputKind` arms. */
type IronwoodParsedBase = ParsedTransaction<ZcashIronwoodParsedOutput, ZcashParsedInput> & {
valueBalance: bigint;
Expand All @@ -123,6 +163,11 @@ type IronwoodParsedBase = ParsedTransaction<ZcashIronwoodParsedOutput, ZcashPars
* {@link combineProof}) follow, in that order. Before signing, the client checks the bundle
* against its own spend-validating key and randomizers with {@link verifyShieldedBundle}.
*
* For custodial MPC shielded wallets — no fixed-script xpub triple at all — the constructor
* is {@link createEmptyKeyless} (no `walletKeys`), and {@link parseShieldedTransaction}
* surfaces the built transaction (`shieldedSpends` / `outputs` / `valueBalance` / `fee`)
* without ever needing the keys.
*
* **Sign first, prove last.** Spend signatures are made over {@link spendSighash}, which
* commits `rk` but neither the proof nor the randomizers `alpha`; the alphas reach the prover
* out of band via {@link getProofRequest}. Signing freezes the bundle, so the proof is always
Expand Down Expand Up @@ -214,6 +259,28 @@ export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt<
return super.parseOutputsWithWalletKeys(walletKeys, options);
}

/**
* The keyless parse — the MPC-shielded shape, and the wallet-keys-free counterpart to
* {@link parseTransactionWithWalletKeys}: the verified shielded spends, every output, the
* bundle's value balance, and the fee. Transparent inputs are not surfaced (a spend bundle
* has none) and transparent outputs are decoded without keys, so the wallet-ownership
* fields (`scriptId`, `derivationPath`) stay null.
*
* Transparent input values are taken from the PSBT's utxo fields as-is — nothing here
* can verify them against the chain — so a transparent-input parse's `fee` trusts those
* fields; the MPC spend shapes carry no transparent inputs, so their `fee` is fully
* verified.
*
* Throws `ZcashParseTransactionError.NotIronwoodV6` on a non-v6 PSBT, and
* `ZcashParseTransactionError.MixedInputKinds` when transparent inputs ride alongside
* shielded spends. After {@link combineProof} the PCZT is gone (that call is terminal),
* so the parse throws `ZcashParseTransactionError.Shielded` rather than reporting no
* shielded side.
*/
parseShieldedTransaction(): ZcashShieldedParsedTransaction {
return this.wasm.parse_shielded_transaction() as ZcashShieldedParsedTransaction;
}

/**
* Create an empty Zcash **v6 (Ironwood)** shielding PSBT, with the consensus branch ID
* determined from block height.
Expand Down Expand Up @@ -272,6 +339,33 @@ export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt<
return new ZcashIronwoodBitGoPsbt(wasm);
}

/**
* Create an empty Zcash **v6 (Ironwood)** shielding PSBT **without** wallet keys — the
* MPC-shielded constructor, with the consensus branch ID determined from block height.
*
* A custodial MPC shielded wallet has no fixed-script xpub triple, so there are no global
* xpubs to stamp: the xpubs {@link createEmpty} embeds are purely informational metadata,
* and every other v6 method reads/writes only the proprietary-map fields this constructor
* also sets. The transparent side uses `addWalletInput` / `addWalletOutput`, the shielded
* side {@link addShieldedSpends} / {@link addShieldedOutput};
* {@link parseShieldedTransaction} surfaces what was built, without needing any keys.
*
* @param network - Zcash network name ("zcash", "zcashTest", "zec", "tzec")
* @param options - Options including blockHeight (at/after NU6.3 activation)
*/
static createEmptyKeyless(
network: ZcashNetworkName,
options: CreateEmptyKeylessOptions,
): ZcashIronwoodBitGoPsbt {
const wasm = WasmBitGoPsbt.create_empty_zcash_v6_bare_at_height(
network,
options.blockHeight,
options.lockTime,
options.expiryHeight,
);
return new ZcashIronwoodBitGoPsbt(wasm);
}

/**
* Not applicable to v6, and not implementable: a broadcast v6 transaction carries the shielded
* side as a proof plus a binding signature, while every v6 operation here needs the PCZT — which
Expand Down
2 changes: 2 additions & 0 deletions packages/wasm-utxo/js/fixedScriptWallet/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ export {
zip302NoMemo,
type ZcashIronwoodParsedOutput,
type ZcashIronwoodParsedTransaction,
type ZcashShieldedParsedTransaction,
type CreateEmptyIronwoodOptions,
type CreateEmptyKeylessOptions,
type CreateEmptyIronwoodWithConsensusBranchIdOptions,
} from "./ZcashIronwoodBitGoPsbt.js";
// Shared Zcash Ironwood (shielded-spend flow) types
Expand Down
18 changes: 18 additions & 0 deletions packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,24 @@ impl BitGoPsbt {
)
}

/// Create an empty Zcash **v6 (Ironwood)** shielding PSBT **without embedding any xpubs**,
/// with the consensus branch id resolved from block height. Delegates to
/// [`ZcashBitGoPsbt::new_v6_bare_at_height`].
///
/// The keyless MPC-shielded constructor: a custodial MPC shielded wallet has no
/// fixed-script xpub triple to stamp. The transparent inputs/outputs use the usual
/// [`Self::add_wallet_input`] / [`Self::add_wallet_output`] machinery, the shielded side
/// the dedicated methods on [`ZcashBitGoPsbt`]; the keyless parse
/// ([`zcash::parse::parse_shielded_transaction`]) surfaces what was built.
pub fn new_zcash_v6_bare_at_height(
network: Network,
block_height: u32,
lock_time: Option<u32>,
expiry_height: Option<u32>,
) -> Result<Self, String> {
zcash::v6::new_bitgo_psbt_v6_bare_at_height(network, block_height, lock_time, expiry_height)
}

/// Create a new empty PSBT with the same network parameters as an existing PSBT
///
/// This is useful for reconstructing PSBTs - it copies:
Expand Down
142 changes: 142 additions & 0 deletions packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,14 @@
//! [`ZcashParsedTransaction`] (per-action output indices, shielded spends, the bundle's
//! value balance). The key-free verifier checks the bundle before any plaintext value is
//! returned.
//!
//! [`parse_shielded_transaction`] is the wallet-keys-free counterpart: the
//! MPC-shielded shape (`shieldedSpends` / `outputs` / `valueBalance` / `fee`) for callers
//! without a fixed-script xpub triple.

use super::super::psbt_wallet_input;
use super::ZcashBitGoPsbt;
use crate::address::networks::from_output_script_with_network;
use crate::fixed_script_wallet::bitgo_psbt::{
BitGoPsbt, ParseTransactionError, ParsedInput, ParsedOutput,
};
Expand Down Expand Up @@ -90,6 +96,26 @@ pub struct ZcashParsedTransaction {
pub is_ironwood_v6: bool,
}

/// The keyless parse of a v6 (Ironwood) transaction — the MPC-shielded shape.
///
/// The wallet-keys-free counterpart to [`ZcashParsedTransaction`]: the verified shielded
/// side, every output, the bundle's value balance, and the fee. Transparent inputs are not
/// surfaced — a spend bundle has none, and the shielding flow's wallet-owned inputs are
/// the keyed parse's job ([`parse_transaction_with_wallet_keys`]).
#[derive(Clone, Debug)]
pub struct ZcashShieldedParsedTransaction {
/// The verified shielded spends, in action order.
pub shielded_spends: Vec<ZcashShieldedSpend>,
/// Every output: transparent first (tx output order), then the shielded ones (action
/// order) — the same order [`parse_transaction_with_wallet_keys`] reports.
pub outputs: Vec<ZcashParsedOutput>,
/// The orchard bundle's value balance (Σv_spend − Σv_out); `0` when the PSBT carries
/// no Ironwood bundle.
pub value_balance: i64,
/// Fee: total input value (transparent inputs + shielded spends) − total output value.
pub fee: u64,
}

/// Errors from the Zcash-specific parse.
#[derive(Debug, strum::IntoStaticStr)]
pub enum ZcashParseTransactionError {
Expand All @@ -100,6 +126,10 @@ pub enum ZcashParseTransactionError {
/// The shielded side could not be read (no usable PCZT, an unencodable address, or a
/// value overflow).
Shielded(String),
/// The keyless shielded parse ([`parse_shielded_transaction`]) was handed a non-v6
/// PSBT: a v4/Sapling PSBT's shielded side cannot be parsed without keys, so the
/// keyless surface is v6-only.
NotIronwoodV6,
/// The shielded side failed key-free verification; the error code is the
/// failing verifier check (`IronwoodVerifyError.<check>`).
Verify(crate::zcash::ironwood_build::IronwoodVerifyError),
Expand All @@ -113,6 +143,10 @@ impl std::fmt::Display for ZcashParseTransactionError {
f,
"a PSBT cannot mix transparent inputs with shielded spends"
),
ZcashParseTransactionError::NotIronwoodV6 => write!(
f,
"the keyless shielded parse requires a v6 (Ironwood) PSBT"
),
ZcashParseTransactionError::Shielded(error) => write!(f, "Shielded side: {error}"),
ZcashParseTransactionError::Verify(error) => {
write!(f, "Shielded side failed verification: {error}")
Expand Down Expand Up @@ -280,6 +314,114 @@ pub(crate) fn parse_transaction_with_wallet_keys(
})
}

/// Parse a v6 (Ironwood) Zcash transaction without wallet keys — the MPC-shielded shape.
///
/// The key-free verifier runs before any plaintext value leaves here: a tampered bundle
/// fails with the failing check's error code ([`ZcashParseTransactionError::Verify`]), and
/// a PCZT already consumed by `combine_ironwood_proof` fails loudly
/// ([`ZcashParseTransactionError::Shielded`]) rather than parsing as bundle-less.
/// Transparent outputs are decoded without keys: the address is the stored
/// `unified_address` (when `add_transparent_output` was given one) or the address the
/// scriptPubKey decodes to. Transparent inputs contribute only their values, taken as-is
/// from the `witness_utxo`/`non_witness_utxo` the PSBT carries — nothing here can verify
/// them against the chain, so `fee` trusts those fields; the MPC spend shapes (deshield /
/// shielded→shielded) carry no transparent inputs at all, so their `fee` rests entirely on
/// verified values. Transparent inputs are never surfaced as wallet inputs, and a PSBT
/// that mixes them with shielded spends fails loudly.
pub(crate) fn parse_shielded_transaction(
bitgo_psbt: &BitGoPsbt,
) -> Result<ZcashShieldedParsedTransaction, ZcashParseTransactionError> {
let z = zcash_psbt(bitgo_psbt)?;
if !z.is_ironwood_v6() {
return Err(ZcashParseTransactionError::NotIronwoodV6);
}
let (shielded_outputs, shielded_spends, value_balance) = shielded_side(z)?;

// One input kind, defense in depth: the adders already refuse the mix, so only a
// PSBT assembled from parts can trip this.
let psbt = bitgo_psbt.psbt();
if !psbt.unsigned_tx.input.is_empty() && !shielded_spends.is_empty() {
return Err(ZcashParseTransactionError::MixedInputKinds);
}

let mut total_input_value = shielded_spends.iter().try_fold(0u64, |sum, spend| {
sum.checked_add(spend.value).ok_or_else(|| {
ZcashParseTransactionError::Shielded(format!(
"action {}: shielded spend value overflow",
spend.action_index
))
})
})?;
for (index, (tx_input, psbt_input)) in
psbt.unsigned_tx.input.iter().zip(&psbt.inputs).enumerate()
{
let value =
psbt_wallet_input::get_output_script_and_value(psbt_input, tx_input.previous_output)
.map(|(_script, amount)| amount.to_sat())
.map_err(|e| ParseTransactionError::Input {
index,
error: psbt_wallet_input::ParseInputError::Utxo(e),
})?;
total_input_value = total_input_value
.checked_add(value)
.ok_or(ParseTransactionError::InputValueOverflow { index })?;
}

let mut outputs = Vec::with_capacity(psbt.unsigned_tx.output.len() + shielded_outputs.len());
let mut total_output_value = 0u64;
for (index, tx_output) in psbt.unsigned_tx.output.iter().enumerate() {
total_output_value = total_output_value
.checked_add(tx_output.value.to_sat())
.ok_or(ParseTransactionError::OutputValueOverflow { index })?;
outputs.push(ZcashParsedOutput {
base: ParsedOutput {
address: transparent_output_address(z, index, &tx_output.script_pubkey),
script: tx_output.script_pubkey.to_bytes(),
value: tx_output.value.to_sat(),
// No wallet keys: ownership is undecidable, so no script id, no derivation.
script_id: None,
paygo: false,
derivation_path: None,
},
is_shielded: false,
action_index: None,
is_change: false,
});
}
for output in shielded_outputs {
total_output_value = total_output_value.checked_add(output.base.value).ok_or(
ParseTransactionError::OutputValueOverflow {
index: outputs.len(),
},
)?;
outputs.push(output);
}

let fee = total_input_value
.checked_sub(total_output_value)
.ok_or(ParseTransactionError::FeeCalculation)?;

Ok(ZcashShieldedParsedTransaction {
shielded_spends,
outputs,
value_balance,
fee,
})
}

/// A transparent output's address without wallet keys: the stored `unified_address` (when
/// `add_transparent_output` was given one, see [`super::propkv`]) or, failing that, the
/// address the scriptPubKey decodes to — `None` for a script with no standard address
/// form.
fn transparent_output_address(
z: &ZcashBitGoPsbt,
index: usize,
script: &miniscript::bitcoin::Script,
) -> Option<String> {
super::propkv::get_transparent_output_unified_address(&z.psbt, index)
.or_else(|| from_output_script_with_network(script, z.network()).ok())
}

/// Wrap the coin-neutral parsed outputs as transparent Zcash outputs.
fn transparent_outputs(outputs: Vec<ParsedOutput>) -> Vec<ZcashParsedOutput> {
outputs
Expand Down
Loading
Loading