From 2ae629d5aee81a355dfc93c9fcffb79844c8e914 Mon Sep 17 00:00:00 2001 From: Veetrag Jain Date: Thu, 22 Oct 2026 17:39:27 +0530 Subject: [PATCH] feat(wasm-utxo): generic parsed-input type and Zcash parsed types Ticket: CSHLD-2022 --- .../js/fixedScriptWallet/BitGoPsbt.ts | 16 +- .../js/fixedScriptWallet/ZcashBitGoPsbt.ts | 12 +- .../ZcashIronwoodBitGoPsbt.ts | 67 +++- .../fixedScriptWallet/ZcashIronwoodTypes.ts | 92 +++++ .../wasm-utxo/js/fixedScriptWallet/index.ts | 13 + packages/wasm-utxo/src/error.rs | 13 + .../src/fixed_script_wallet/bitgo_psbt/mod.rs | 95 ++--- .../bitgo_psbt/psbt_wallet_output.rs | 6 - .../bitgo_psbt/zcash/ironwood.rs | 90 +---- .../bitgo_psbt/zcash/mod.rs | 3 +- .../bitgo_psbt/zcash/parse.rs | 339 ++++++++++++++++++ .../bitgo_psbt/zcash/tests/ironwood.rs | 146 +++++++- .../bitgo_psbt/zcash/v6.rs | 2 +- .../src/wasm/fixed_script_wallet/mod.rs | 15 + .../wasm/fixed_script_wallet/zcash_psbt.rs | 114 ++++++ .../wasm-utxo/src/wasm/try_into_js_value.rs | 9 +- .../parseTransactionWithWalletKeys.ts | 27 ++ .../test/fixedScript/zcashIronwoodPsbt.ts | 50 +++ packages/wasm-utxo/test/types/parsedTypes.ts | 104 ++++++ 19 files changed, 1046 insertions(+), 167 deletions(-) create mode 100644 packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodTypes.ts create mode 100644 packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/parse.rs create mode 100644 packages/wasm-utxo/test/types/parsedTypes.ts diff --git a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts index 53636edf178..a0738051b05 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/BitGoPsbt.ts @@ -55,8 +55,11 @@ export type ParsedOutput = { derivationPath: string | null; }; -export type ParsedTransaction = { - inputs: ParsedInput[]; +export type ParsedTransaction< + TOutput extends ParsedOutput = ParsedOutput, + TInput extends ParsedInput = ParsedInput, +> = { + inputs: TInput[]; outputs: TOutput[]; spendAmount: bigint; minerFee: bigint; @@ -135,7 +138,10 @@ export type HydrationUnspent = | { chain: number; index: number; value: bigint } // wallet input | { pubkey: Uint8Array; value: bigint }; // P2SH-P2PK replay protection input -export class BitGoPsbt +export class BitGoPsbt< + TOutput extends ParsedOutput = ParsedOutput, + TInput extends ParsedInput = ParsedInput, + > extends PsbtBase implements IPsbtWithAddress { @@ -619,7 +625,7 @@ export class BitGoPsbt parseTransactionWithWalletKeys( walletKeys: WalletKeysArg, options: ParseTransactionOptions, - ): ParsedTransaction { + ): ParsedTransaction { const keys = RootWalletKeys.from(walletKeys); const rp = ReplayProtection.from(options.replayProtection, this._wasm.network()); const pubkeys = options.payGoPubkeys?.map((arg) => ECPair.from(arg).wasm); @@ -627,7 +633,7 @@ export class BitGoPsbt keys.wasm, rp.wasm, pubkeys, - ) as ParsedTransaction; + ) as ParsedTransaction; } /** diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts index fa59c8e46f8..e8f604d5cb7 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashBitGoPsbt.ts @@ -8,6 +8,7 @@ import { BitGoPsbt, type CreateEmptyOptions, type HydrationUnspent, + type ParsedInput, type ParsedOutput, } from "./BitGoPsbt.js"; import { ZcashTransaction, type ITransaction } from "../transaction.js"; @@ -15,6 +16,12 @@ import { ZcashTransaction, type ITransaction } from "../transaction.js"; /** Zcash network names */ export type ZcashNetworkName = "zcash" | "zcashTest" | "zec" | "tzec"; +/** + * A transparent Zcash input. Same shape as {@link ParsedInput}; the additive + * `isShielded: false` lets callers discriminate inputs uniformly across the Zcash parse + * results (shielded spends come back as `ZcashShieldedSpend`s instead). + */ +export type ZcashParsedInput = ParsedInput & { isShielded: false }; export type ZcashParsedOutput = ParsedOutput & { /** * True for a shielded (Orchard/Ironwood) output. Such an output has no `unsigned_tx` entry of @@ -83,7 +90,10 @@ export type CreateEmptyZcashWithConsensusBranchIdOptions = CreateEmptyOptions & * const psbt = ZcashBitGoPsbt.fromBytes(bytes, "zcash"); * ``` */ -export class ZcashBitGoPsbt extends BitGoPsbt { +export class ZcashBitGoPsbt< + TOutput extends ZcashParsedOutput = ZcashParsedOutput, + TInput extends ZcashParsedInput = ZcashParsedInput, +> extends BitGoPsbt { /** * Create an empty Zcash PSBT with consensus branch ID determined from block height * diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts index 41d795816ff..14555b67007 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts @@ -6,7 +6,15 @@ import { IRONWOOD_VERSION_GROUP_ID, ZcashBitGoPsbt, type ZcashNetworkName, + type ZcashParsedInput, + type ZcashParsedOutput, } from "./ZcashBitGoPsbt.js"; +import { + type ParsedTransaction, + type ParseOutputsOptions, + type ParseTransactionOptions, +} from "./BitGoPsbt.js"; +import { type ZcashShieldedSpend } from "./ZcashIronwoodTypes.js"; /** * Options for creating an empty Zcash v6 (Ironwood) shielding PSBT. @@ -57,6 +65,33 @@ export function zip302NoMemo(): Uint8Array { return memo; } +/** + * The parsed shape of a Zcash output through the Ironwood parse: the Zcash output shape plus + * the PCZT action index (`null` for transparent outputs). + */ +export type ZcashIronwoodParsedOutput = ZcashParsedOutput & { + actionIndex: number | null; +}; + +/** + * The parsed shape of a v6 (Ironwood) transaction. A discriminated union on `inputKind`: + * a shielded-input PSBT has no transparent inputs at all, and a transparent-input PSBT has + * no shielded spends. `valueBalance` is the orchard bundle's value balance (Σv_spend − + * Σv_out), `0` when there is no bundle. + */ +export type ZcashIronwoodParsedTransaction = + | (IronwoodParsedBase & { inputKind: "transparent"; shieldedSpends: [] }) + | (IronwoodParsedBase & { + inputKind: "shielded"; + inputs: []; + shieldedSpends: ZcashShieldedSpend[]; + }); + +/** The parse fields shared by both `inputKind` arms. */ +type IronwoodParsedBase = ParsedTransaction & { + valueBalance: bigint; +}; + /** * A Zcash **v6 (Ironwood / NU6.3)** shielding PSBT. * @@ -82,7 +117,37 @@ export function zip302NoMemo(): Uint8Array { * const tx = psbt.combineProof(proof); * ``` */ -export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt { +export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt< + ZcashIronwoodParsedOutput, + ZcashParsedInput +> { + /** + * Parse the transaction through the Zcash parse, which surfaces the shielded side the + * generic parse refuses to drop: per-action output indices, the shielded spends, and the + * bundle's value balance. The plaintext spend info is unverified until the key-free + * verifier lands (plan PR 4). + */ + override parseTransactionWithWalletKeys( + walletKeys: WalletKeysArg, + options: ParseTransactionOptions, + ): ZcashIronwoodParsedTransaction { + return super.parseTransactionWithWalletKeys( + walletKeys, + options, + ) as ZcashIronwoodParsedTransaction; + } + + /** + * Parse outputs through the Zcash parse: every output carries `isShielded` and, for + * shielded outputs, its `actionIndex`. + */ + override parseOutputsWithWalletKeys( + walletKeys: WalletKeysArg, + options?: ParseOutputsOptions, + ): ZcashIronwoodParsedOutput[] { + return super.parseOutputsWithWalletKeys(walletKeys, options); + } + /** * Create an empty Zcash **v6 (Ironwood)** shielding PSBT, with the consensus branch ID * determined from block height. diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodTypes.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodTypes.ts new file mode 100644 index 00000000000..e9ba632c5ad --- /dev/null +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodTypes.ts @@ -0,0 +1,92 @@ +/** + * Shared Zcash Ironwood (v6) types for the shielded-spend flow. + * + * These describe what the build service, the client and KMS each see as the PSBT moves + * through the flow in `docs/ironwood-shielded-spend-plan.md` §2. Parse-facing types live in + * `ZcashIronwoodBitGoPsbt.ts`; this module holds the shared vocabulary. + */ + +/** One shielded note to spend, as passed to `addShieldedSpends` (plan PR 5). */ +export type ZcashShieldedSpendRequest = { + /** Raw 43-byte Orchard recipient (diversifier ‖ pk_d). */ + recipient: Uint8Array; + /** Note value in zatoshi. */ + value: bigint; + /** The note's rho (the nullifier seed the spend re-derives). */ + rho: Uint8Array; + /** The note's random seed. */ + rseed: Uint8Array; + /** The note commitment the on-chain action carried. */ + cmx: Uint8Array; + /** Merkle witness (position + auth path) proving `cmx` under the bundle's anchor. */ + witness: { position: number; authPath: Uint8Array[] }; + /** The spend's nullifier, from the note store or from KMS. */ + nullifier: Uint8Array; +}; + +/** One shielded output to create, as passed to `addShieldedSpends` (plan PR 5). */ +export type ZcashShieldedOutputRequest = { + /** Raw 43-byte Orchard recipient. */ + recipient: Uint8Array; + /** Output value in zatoshi (> 0). */ + amount: bigint; + /** ZIP-302 memo; defaults to the no-memo encoding. */ + memo?: Uint8Array; + /** Full Unified Address the output was addressed to, preserved verbatim. */ + unifiedAddress?: string; +}; + +/** + * The randomizer state of a shielded spend: `placeholder` while the spend still carries the + * wallet's spend-validating key as its `rk`, `installed` once the signer delivered the + * randomized `(alpha, rk)` pair. + */ +export const IronwoodRandomizerState = ["placeholder", "installed"] as const; +export type IronwoodRandomizerState = (typeof IronwoodRandomizerState)[number]; + +/** Whether a PSBT's inputs are transparent or shielded — exactly one per PSBT. */ +export const ZcashInputKind = ["transparent", "shielded"] as const; +export type ZcashInputKind = (typeof ZcashInputKind)[number]; + +/** One real shielded spend, as surfaced by the Zcash parse. */ +export type ZcashShieldedSpend = { + /** The spend's action index in the orchard bundle. */ + actionIndex: number; + /** Note value in zatoshi. */ + value: bigint; + /** Raw Orchard recipient (43 bytes). */ + recipient: Uint8Array; + /** The nullifier this spend reveals. */ + nullifier: Uint8Array; + /** Randomizer state of the spend's `rk` (see {@link IronwoodRandomizerState}). */ + randomizer: IronwoodRandomizerState; + /** Whether a spend-auth signature is installed for this spend. */ + signed: boolean; +}; + +/** The client-side verification report of a shielded bundle (plan PR 4). */ +export type IronwoodVerificationReport = { + /** The bundle's real spends, as verified. */ + spends: ZcashShieldedSpend[]; + /** The bundle's real outputs. */ + outputs: Array<{ actionIndex: number; value: bigint; recipient: Uint8Array }>; + /** The bundle's value balance (Σv_spend − Σv_out). */ + valueBalance: bigint; + /** The anchor the spends' witnesses commit to. */ + anchor: Uint8Array; +}; + +/** Orchestration state of the shielded side of a spend PSBT (plan PR 7). */ +export type IronwoodShieldedStatus = { + inputKind: ZcashInputKind; + /** Action indices whose `out_ciphertext` is still pending. */ + pendingOutCiphertexts: number[]; + /** Action indices whose randomizer `(alpha, rk)` is still pending. */ + pendingRandomizers: number[]; + /** Action indices whose spend-auth signature is still pending. */ + pendingSpendSignatures: number[]; + /** Whether a Halo2 proof is installed in the PCZT. */ + hasProof: boolean; + /** Whether the PSBT is ready for `combineProof`. */ + readyToCombine: boolean; +}; diff --git a/packages/wasm-utxo/js/fixedScriptWallet/index.ts b/packages/wasm-utxo/js/fixedScriptWallet/index.ts index 2670f2f0e4f..793aa5fe709 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/index.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/index.ts @@ -43,6 +43,7 @@ export { BitGoKeySubtype, type PsbtKvKey } from "./BitGoKeySubtype.js"; export { ZcashBitGoPsbt, type ZcashNetworkName, + type ZcashParsedInput, type ZcashParsedOutput, type ZcashTransactionVersion, type CreateEmptyZcashOptions, @@ -53,9 +54,21 @@ export { ZcashPsbt, type ZcashPsbtInstance } from "./ZcashPsbt.js"; export { ZcashIronwoodBitGoPsbt, zip302NoMemo, + type ZcashIronwoodParsedOutput, + type ZcashIronwoodParsedTransaction, type CreateEmptyIronwoodOptions, type CreateEmptyIronwoodWithConsensusBranchIdOptions, } from "./ZcashIronwoodBitGoPsbt.js"; +// Shared Zcash Ironwood (shielded-spend flow) types +export type { + ZcashShieldedSpendRequest, + ZcashShieldedOutputRequest, + IronwoodRandomizerState, + ZcashInputKind, + ZcashShieldedSpend, + IronwoodVerificationReport, + IronwoodShieldedStatus, +} from "./ZcashIronwoodTypes.js"; // Zcash ZIP-316 Unified Address export { ZcashUnifiedAddress } from "./ZcashUnifiedAddress.js"; diff --git a/packages/wasm-utxo/src/error.rs b/packages/wasm-utxo/src/error.rs index 5380a29a5ab..3700c705135 100644 --- a/packages/wasm-utxo/src/error.rs +++ b/packages/wasm-utxo/src/error.rs @@ -24,6 +24,7 @@ macro_rules! impl_wasm_error_code { pub enum WasmUtxoError { StringError(String), Parse(ParseTransactionError), + ZcashParse(crate::fixed_script_wallet::bitgo_psbt::zcash::parse::ZcashParseTransactionError), UnifiedAddress(crate::zcash::unified_address::UnifiedAddressError), ZcashV6(crate::zcash::v6::ZcashV6Error), Ironwood(crate::zcash::ironwood_build::IronwoodBuildError), @@ -39,6 +40,7 @@ impl fmt::Display for WasmUtxoError { match self { WasmUtxoError::StringError(s) => write!(f, "{}", s), WasmUtxoError::Parse(e) => write!(f, "{}", e), + WasmUtxoError::ZcashParse(e) => write!(f, "{}", e), WasmUtxoError::UnifiedAddress(e) => write!(f, "{}", e), WasmUtxoError::ZcashV6(e) => write!(f, "{}", e), WasmUtxoError::Ironwood(e) => write!(f, "{}", e), @@ -54,6 +56,7 @@ impl WasmErrorCode for WasmUtxoError { match self { WasmUtxoError::StringError(_) => "WasmUtxoError.StringError".to_string(), WasmUtxoError::Parse(e) => e.code(), + WasmUtxoError::ZcashParse(e) => e.code(), WasmUtxoError::UnifiedAddress(e) => e.code(), WasmUtxoError::ZcashV6(e) => e.code(), WasmUtxoError::Ironwood(e) => e.code(), @@ -100,6 +103,16 @@ impl From for WasmUtxoError { } } +impl From + for WasmUtxoError +{ + fn from( + err: crate::fixed_script_wallet::bitgo_psbt::zcash::parse::ZcashParseTransactionError, + ) -> Self { + WasmUtxoError::ZcashParse(err) + } +} + impl From for WasmUtxoError { fn from(err: crate::zcash::unified_address::UnifiedAddressError) -> Self { WasmUtxoError::UnifiedAddress(err) 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 3f9ec5deb16..954a03ca43e 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 @@ -156,10 +156,10 @@ pub enum ParseTransactionError { SpendAmountOverflow { index: usize }, /// Fee calculation error (outputs exceed inputs) FeeCalculation, - /// Failed to read the shielded (Ironwood) output's value - ShieldedOutput(String), - /// Failed to read the shielded (Ironwood) spends' values, or their total overflowed - ShieldedSpend(String), + /// A Zcash PSBT carrying shielded (Ironwood) data must be parsed with the Zcash parse + /// ([`crate::fixed_script_wallet::bitgo_psbt::zcash::parse`]) — the generic parse + /// refuses rather than silently dropping the shielded side. + UseZcashParse, } impl std::fmt::Display for ParseTransactionError { @@ -183,11 +183,11 @@ impl std::fmt::Display for ParseTransactionError { ParseTransactionError::FeeCalculation => { write!(f, "Fee calculation error: outputs exceed inputs") } - ParseTransactionError::ShieldedOutput(error) => { - write!(f, "Shielded output: {}", error) - } - ParseTransactionError::ShieldedSpend(error) => { - write!(f, "Shielded spend: {}", error) + ParseTransactionError::UseZcashParse => { + write!( + f, + "this Zcash PSBT carries shielded data; use the Zcash parse" + ) } } } @@ -2550,7 +2550,7 @@ impl BitGoPsbt { Ok(()) } - fn parse_inputs( + pub(crate) fn parse_inputs( &self, wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, replay_protection: &crate::fixed_script_wallet::ReplayProtection, @@ -2592,7 +2592,7 @@ impl BitGoPsbt { /// # Note /// This method does NOT validate wallet inputs. It only parses outputs to identify /// which ones belong to the provided wallet keys. - fn parse_outputs( + pub(crate) fn parse_outputs( &self, wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, paygo_pubkeys: &[secp256k1::PublicKey], @@ -2618,7 +2618,7 @@ impl BitGoPsbt { error, })?; // Prefer the caller's original Unified Address (if `add_transparent_output` was - // given one for this output): mirrors `shielded_outputs`'s treatment of + // given one for this output): mirrors the Zcash parse's treatment of // `add_ironwood_output`'s UA — the caller-supplied UA, not a bare address // reconstructed from the scriptPubKey alone, is what a client actually pasted in. if let Some(ua) = zcash::v6::transparent_output_address_override(self, output_index) @@ -2630,33 +2630,14 @@ impl BitGoPsbt { .collect() } - /// 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> { - zcash::ironwood::fold_shielded_outputs(self) - } - - /// Total value (zatoshi) of this PSBT's real shielded (Ironwood) spends — `0` if this isn't a - /// v6 (Ironwood) PSBT, or no shielded spend has been added. Like the shielded outputs, the - /// spends live in the proprietary-map PCZT, invisible to transparent-input parsing, so - /// `parse_transaction_with_wallet_keys` folds this into its input total. - fn shielded_spend_total(&self) -> Result { - zcash::ironwood::fold_shielded_spend_total(self) - } - /// Calculate total input value from parsed inputs /// /// # Returns /// - `Ok(u64)` with total input value /// - `Err(ParseTransactionError)` if overflow occurs - fn sum_input_values(parsed_inputs: &[ParsedInput]) -> Result { + pub(crate) fn sum_input_values( + parsed_inputs: &[ParsedInput], + ) -> Result { parsed_inputs .iter() .enumerate() @@ -2672,7 +2653,7 @@ impl BitGoPsbt { /// # Returns /// - `Ok((total_value, spend_amount))` with total output value and external spend amount /// - `Err(ParseTransactionError)` if overflow occurs - fn sum_output_values( + pub(crate) fn sum_output_values( tx_outputs: &[miniscript::bitcoin::TxOut], parsed_outputs: &[ParsedOutput], ) -> Result<(u64, u64), ParseTransactionError> { @@ -3271,11 +3252,12 @@ impl BitGoPsbt { wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, paygo_pubkeys: &[secp256k1::PublicKey], ) -> Result, ParseTransactionError> { - let mut outputs = self.parse_outputs(wallet_keys, paygo_pubkeys)?; - for (output, _amount) in self.shielded_outputs()? { - outputs.push(output); + // A Zcash PSBT carrying Ironwood (shielded) data must go through the Zcash parse — + // the generic one would silently drop the shielded outputs. + if zcash::parse::carries_shielded_data(self) { + return Err(ParseTransactionError::UseZcashParse); } - Ok(outputs) + self.parse_outputs(wallet_keys, paygo_pubkeys) } /// Parse transaction with wallet keys to identify wallet inputs/outputs and calculate metrics @@ -3294,41 +3276,22 @@ impl BitGoPsbt { replay_protection: &crate::fixed_script_wallet::ReplayProtection, paygo_pubkeys: &[secp256k1::PublicKey], ) -> Result { + // A Zcash PSBT carrying Ironwood (shielded) data must go through the Zcash parse + // (`zcash::parse`) — the generic parse would silently drop the shielded side. + if zcash::parse::carries_shielded_data(self) { + return Err(ParseTransactionError::UseZcashParse); + } let psbt = self.psbt(); // Parse inputs and outputs let parsed_inputs = self.parse_inputs(wallet_keys, replay_protection)?; - let mut parsed_outputs = self.parse_outputs(wallet_keys, paygo_pubkeys)?; + let parsed_outputs = self.parse_outputs(wallet_keys, paygo_pubkeys)?; // Calculate totals - // Shielded spends fund the transaction alongside the transparent inputs (unshielding / - // mixed transactions); without them `miner_fee` underflows. - let total_input_value = Self::sum_input_values(&parsed_inputs)? - .checked_add(self.shielded_spend_total()?) - .ok_or_else(|| { - ParseTransactionError::ShieldedSpend("total input value overflow".to_string()) - })?; - let (mut total_output_value, mut spend_amount) = + let total_input_value = Self::sum_input_values(&parsed_inputs)?; + let (total_output_value, spend_amount) = Self::sum_output_values(&psbt.unsigned_tx.output, &parsed_outputs)?; - // 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. - 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( - 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) diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_output.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_output.rs index c97481b162d..f9db3c62edf 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_output.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/psbt_wallet_output.rs @@ -15,11 +15,6 @@ 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, - /// Whether this output is a shielded (Orchard/Ironwood) output rather than a transparent one. - /// `None` for coins that don't support shielded outputs. Always `Some(false)` for outputs - /// parsed from `tx_output`/`psbt_output` on a Zcash PSBT — set to `Some(true)` by the caller - /// when synthesizing a `ParsedOutput` for the shielded side of a v6 (Ironwood) transaction. - pub is_shielded: Option, } impl ParsedOutput { @@ -64,7 +59,6 @@ impl ParsedOutput { script_id, paygo, derivation_path, - is_shielded: matches!(network, Network::Zcash | Network::ZcashTestnet).then_some(false), }) } diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood.rs index 44d1d14e122..3ea5a5a5e05 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood.rs @@ -5,8 +5,7 @@ use miniscript::bitcoin::Transaction; -use super::propkv; -use super::{BitGoPsbt, ParseTransactionError, ParsedOutput, ZcashBitGoPsbt}; +use super::{BitGoPsbt, ZcashBitGoPsbt}; /// One requested Ironwood shielded output, as passed to /// [`ZcashBitGoPsbt::add_ironwood_outputs`]. @@ -70,6 +69,12 @@ pub struct IronwoodSpendInfo { pub value: u64, /// The nullifier this spend reveals (committed in the action data). pub nullifier: [u8; 32], + /// Raw 43-byte Orchard recipient of the spent note. + pub recipient: crate::zcash::ironwood_build::OrchardAddressBytes, + /// Whether the signer replaced the Constructor's placeholder `rk` (the wire marker). + pub rk_replaced: bool, + /// Whether a spend-auth signature is installed. + pub signed: bool, } /// The spend/output/fee accounting view of the stored PCZT: real spends, real outputs, the @@ -687,10 +692,17 @@ impl ZcashBitGoPsbt { .value() .map(|v| v.inner()) .ok_or_else(|| "real spend is missing its plaintext value".to_string())?; + let recipient = spend + .recipient() + .map(|address| address.to_raw_address_bytes()) + .ok_or_else(|| "real spend is missing its plaintext recipient".to_string())?; Ok(IronwoodSpendInfo { action_index, value, nullifier: spend.nullifier().to_bytes(), + recipient, + rk_replaced: crate::zcash::ironwood_pczt::spend_rk_replaced(spend)?, + signed: spend.spend_auth_sig().is_some(), }) }) .collect() @@ -1056,77 +1068,3 @@ pub(crate) fn estimated_script_sig_len(input: &crate::bitcoin::psbt::Input) -> u None => zip317::p2pkh_script_sig_len(), } } - -/// 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. -pub(crate) fn fold_shielded_outputs( - psbt: &BitGoPsbt, -) -> Result, ParseTransactionError> { - let BitGoPsbt::Zcash(z, _) = psbt else { - return Ok(Vec::new()); - }; - 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, - psbt.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() -} - -/// Total value (zatoshi) of this PSBT's real shielded (Ironwood) spends — `0` if this isn't a -/// v6 (Ironwood) PSBT, or no shielded spend has been added. Like the shielded outputs, the -/// spends live in the proprietary-map PCZT, invisible to transparent-input parsing, so -/// `parse_transaction_with_wallet_keys` folds this into its input total. -pub(crate) fn fold_shielded_spend_total(psbt: &BitGoPsbt) -> Result { - let BitGoPsbt::Zcash(z, _) = psbt else { - return Ok(0); - }; - z.ironwood_shielded_spends_info() - .map_err(ParseTransactionError::ShieldedSpend)? - .iter() - .try_fold(0u64, |total, spend| { - total.checked_add(spend.value).ok_or_else(|| { - ParseTransactionError::ShieldedSpend(format!( - "action {}: value overflow", - spend.action_index - )) - }) - }) -} diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/mod.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/mod.rs index 83e4657e42f..b607f45b88b 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/mod.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/mod.rs @@ -13,6 +13,7 @@ pub use crate::zcash::transaction::{ }; pub(crate) mod ironwood; +pub(crate) mod parse; pub(crate) mod propkv; pub(crate) mod v6; @@ -21,7 +22,7 @@ pub use ironwood::{ }; pub use v6::VerifyV6SignatureError; -use super::{psbt_wallet_input, BitGoPsbt, DeserializeError, ParseTransactionError, ParsedOutput}; +use super::{psbt_wallet_input, BitGoPsbt, DeserializeError}; /// Tests for the Zcash modules, split by subject in [`tests`]. #[cfg(test)] diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/parse.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/parse.rs new file mode 100644 index 00000000000..9b736b1b84d --- /dev/null +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/parse.rs @@ -0,0 +1,339 @@ +//! Zcash-specific parsed-transaction types and the parse entry points that surface the +//! shielded (Ironwood) side alongside the transparent one. +//! +//! The coin-neutral parse on [`BitGoPsbt`] knows nothing about the shielded side: on a +//! PSBT that carries Ironwood data it fails loudly with +//! [`ParseTransactionError::UseZcashParse`] instead of silently dropping it. This module +//! composes that generic machinery with the shielded fold, producing the richer +//! [`ZcashParsedTransaction`] (per-action output indices, shielded spends, the bundle's +//! value balance). The plaintext spend info is unverified until the key-free verifier +//! lands (plan PR 4). + +use super::ZcashBitGoPsbt; +use crate::fixed_script_wallet::bitgo_psbt::{ + BitGoPsbt, ParseTransactionError, ParsedInput, ParsedOutput, +}; +use crate::fixed_script_wallet::{ReplayProtection, RootWalletKeys}; +use crate::zcash::ironwood_build::OrchardAddressBytes; + +/// One shielded (Ironwood) spend, as surfaced by the parse. +/// +/// Plaintext values from the PCZT, not yet verified against the bundle's commitments — +/// the key-free verifier (plan PR 4) hardens this. +#[derive(Clone, Debug)] +pub struct ZcashShieldedSpend { + /// The spend's action index in the orchard bundle. + pub action_index: usize, + /// Note value in zatoshi. + pub value: u64, + /// Raw 43-byte Orchard recipient of the spent note. + pub recipient: OrchardAddressBytes, + /// The nullifier this spend reveals (committed in the action data). + pub nullifier: [u8; 32], + /// Whether the signer replaced the Constructor's placeholder `rk` (plan PR 7 derives + /// this from `alpha` instead of the wire marker). + pub rk_replaced: bool, + /// Whether a spend-auth signature is installed. + pub signed: bool, +} + +/// A transparent Zcash input. Same shape as [`ParsedInput`]; the JS layer adds the +/// additive `isShielded: false`. +#[derive(Clone, Debug)] +pub struct ZcashParsedInput { + pub base: ParsedInput, +} + +/// A Zcash output: the transparent parse shape plus the shielded marker and, for shielded +/// outputs, the PCZT action index. +#[derive(Clone, Debug)] +pub struct ZcashParsedOutput { + pub base: ParsedOutput, + pub is_shielded: bool, + /// The output's action index in the orchard bundle; `None` for transparent outputs. + pub action_index: Option, +} + +/// The inputs of a Zcash transaction: transparent, or the shielded spends of a deshield / +/// shielded-to-shielded bundle. +#[derive(Clone, Debug)] +pub enum ZcashParsedInputs { + Transparent(Vec), + Shielded(Vec), +} + +/// The parsed view of a Zcash transaction, including the shielded side. +#[derive(Clone, Debug)] +pub struct ZcashParsedTransaction { + pub inputs: ZcashParsedInputs, + pub outputs: Vec, + pub spend_amount: u64, + pub miner_fee: u64, + pub virtual_size: u32, + /// The orchard bundle's value balance (Σv_spend − Σv_out — negative when the shielded + /// pool gains value); `0` when the PSBT carries no Ironwood bundle. + pub value_balance: i64, + /// Whether this is a v6 (Ironwood) PSBT. The JS layer only exposes the Ironwood fields + /// (`inputKind`, `shieldedSpends`, `valueBalance`) for v6. + pub is_ironwood_v6: bool, +} + +/// Errors from the Zcash-specific parse. +#[derive(Debug, strum::IntoStaticStr)] +pub enum ZcashParseTransactionError { + /// The transparent side failed to parse; the shielded side is untouched. + Transparent(ParseTransactionError), + /// The PSBT mixes transparent inputs with shielded spends. + MixedInputKinds, + /// The shielded side could not be read from the PCZT plaintext. + Shielded(String), +} + +impl std::fmt::Display for ZcashParseTransactionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ZcashParseTransactionError::Transparent(error) => write!(f, "{error}"), + ZcashParseTransactionError::MixedInputKinds => write!( + f, + "a PSBT cannot mix transparent inputs with shielded spends" + ), + ZcashParseTransactionError::Shielded(error) => write!(f, "Shielded side: {error}"), + } + } +} + +impl std::error::Error for ZcashParseTransactionError {} + +impl From for ZcashParseTransactionError { + fn from(error: ParseTransactionError) -> Self { + ZcashParseTransactionError::Transparent(error) + } +} + +impl crate::error::WasmErrorCode for ZcashParseTransactionError { + fn code(&self) -> String { + match self { + // Transparent failures keep the coin-neutral parse's codes + // (`ParseTransactionError.*`), exactly as before the Zcash parse existed. + ZcashParseTransactionError::Transparent(error) => { + crate::error::WasmErrorCode::code(error) + } + _ => format!("ZcashParseTransactionError.{}", <&'static str>::from(self)), + } + } +} + +/// Whether `bitgo_psbt` carries Ironwood (shielded) data the generic parse cannot see: a +/// stored PCZT, or one already extracted (its shielded side existed and was consumed). +pub(crate) fn carries_shielded_data(bitgo_psbt: &BitGoPsbt) -> bool { + match bitgo_psbt { + BitGoPsbt::Zcash(z, _) => { + super::propkv::has_ironwood_pczt(&z.psbt) + || super::propkv::is_ironwood_extracted(&z.psbt) + } + _ => false, + } +} + +/// The Zcash PSBT behind `bitgo_psbt`; the Zcash parse is only routed Zcash PSBTs. +fn zcash_psbt(bitgo_psbt: &BitGoPsbt) -> Result<&ZcashBitGoPsbt, ZcashParseTransactionError> { + match bitgo_psbt { + BitGoPsbt::Zcash(z, _) => Ok(z), + _ => Err(ZcashParseTransactionError::Shielded( + "the Zcash parse requires a Zcash PSBT".to_string(), + )), + } +} + +/// Parse the Zcash outputs (transparent + shielded) with wallet keys. +/// +/// Reads only the outputs: unlike [`parse_transaction_with_wallet_keys`], it never touches +/// the transparent inputs or the bundle-level value accounting. +pub(crate) fn parse_outputs_with_wallet_keys( + bitgo_psbt: &BitGoPsbt, + wallet_keys: &RootWalletKeys, + paygo_pubkeys: &[miniscript::bitcoin::secp256k1::PublicKey], +) -> Result, ZcashParseTransactionError> { + let z = zcash_psbt(bitgo_psbt)?; + let shielded_outputs = z + .ironwood_shielded_outputs_info() + .map_err(ZcashParseTransactionError::Shielded)?; + let mut outputs = transparent_outputs(bitgo_psbt.parse_outputs(wallet_keys, paygo_pubkeys)?); + outputs.extend(shielded_parsed_outputs(z, &shielded_outputs)?); + Ok(outputs) +} + +/// Parse the Zcash transaction with wallet keys, surfacing the shielded side. +pub(crate) fn parse_transaction_with_wallet_keys( + bitgo_psbt: &BitGoPsbt, + wallet_keys: &RootWalletKeys, + replay_protection: &ReplayProtection, + paygo_pubkeys: &[miniscript::bitcoin::secp256k1::PublicKey], +) -> Result { + let z = zcash_psbt(bitgo_psbt)?; + let (shielded_outputs, shielded_spends, value_balance) = shielded_side(z)?; + + // Transparent side, shared with the coin-neutral parse. + let transparent_inputs = bitgo_psbt.parse_inputs(wallet_keys, replay_protection)?; + if !transparent_inputs.is_empty() && !shielded_spends.is_empty() { + return Err(ZcashParseTransactionError::MixedInputKinds); + } + let parsed_transparent_outputs = bitgo_psbt.parse_outputs(wallet_keys, paygo_pubkeys)?; + + // Totals: shielded spends fund the transaction in place of transparent inputs + // (deshield / shielded-to-shielded); without them `miner_fee` underflows. + let mut shielded_spend_total = 0u64; + for spend in &shielded_spends { + shielded_spend_total = shielded_spend_total + .checked_add(spend.value) + .ok_or_else(|| { + ZcashParseTransactionError::Shielded(format!( + "action {}: shielded spend value overflow", + spend.action_index + )) + })?; + } + let total_input_value = BitGoPsbt::sum_input_values(&transparent_inputs)? + .checked_add(shielded_spend_total) + .ok_or(ZcashParseTransactionError::Shielded( + "total input value overflow".to_string(), + ))?; + + let unsigned_tx = &bitgo_psbt.psbt().unsigned_tx; + let (mut total_output_value, mut spend_amount) = + BitGoPsbt::sum_output_values(&unsigned_tx.output, &parsed_transparent_outputs)?; + + // Fold in the shielded outputs, if any: they're invisible to the transparent-only + // parsing above, so without this they'd silently vanish into `miner_fee` and + // undercount the send. + let mut outputs = transparent_outputs(parsed_transparent_outputs); + for output in shielded_outputs { + let output_index = outputs.len(); + total_output_value = total_output_value.checked_add(output.base.value).ok_or( + ParseTransactionError::OutputValueOverflow { + index: output_index, + }, + )?; + spend_amount = spend_amount.checked_add(output.base.value).ok_or( + ParseTransactionError::SpendAmountOverflow { + index: output_index, + }, + )?; + outputs.push(output); + } + + // Calculate miner fee + let miner_fee = total_input_value + .checked_sub(total_output_value) + .ok_or(ParseTransactionError::FeeCalculation)?; + + // Calculate virtual size from unsigned transaction weight + let virtual_size = unsigned_tx.weight().to_vbytes_ceil() as u32; + + let inputs = if shielded_spends.is_empty() { + ZcashParsedInputs::Transparent( + transparent_inputs + .into_iter() + .map(|base| ZcashParsedInput { base }) + .collect(), + ) + } else { + ZcashParsedInputs::Shielded(shielded_spends) + }; + + Ok(ZcashParsedTransaction { + inputs, + outputs, + spend_amount, + miner_fee, + virtual_size, + value_balance, + is_ironwood_v6: z.is_ironwood_v6(), + }) +} + +/// Wrap the coin-neutral parsed outputs as transparent Zcash outputs. +fn transparent_outputs(outputs: Vec) -> Vec { + outputs + .into_iter() + .map(|base| ZcashParsedOutput { + base, + is_shielded: false, + action_index: None, + }) + .collect() +} + +/// Synthesize a [`ZcashParsedOutput`] for every real shielded output +/// `(action_index, value, recipient)`. +fn shielded_parsed_outputs( + z: &ZcashBitGoPsbt, + shielded_outputs: &[(usize, u64, OrchardAddressBytes)], +) -> Result, ZcashParseTransactionError> { + shielded_outputs + .iter() + .map(|(action_index, value, recipient)| { + // Prefer the caller's original Unified Address (if `add_ironwood_output` 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 super::propkv::get_ironwood_unified_address(&z.psbt, *action_index) + { + Some(ua) => ua, + None => crate::zcash::unified_address::encode_orchard_receiver( + recipient, + z.network().to_coin_name(), + ) + .map_err(|e| ZcashParseTransactionError::Shielded(e.to_string()))?, + }; + Ok(ZcashParsedOutput { + base: 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: *value, + script_id: None, + paygo: false, + derivation_path: None, + }, + is_shielded: true, + action_index: Some(*action_index), + }) + }) + .collect() +} + +/// The shielded side of the PSBT: `(shielded outputs, shielded spends, value balance)`. +/// +/// Empty when no Ironwood bundle was ever added; a loud error when it was already extracted +/// (so an extraction can never silently look like a PSBT that never carried a shielded +/// side). +fn shielded_side( + z: &ZcashBitGoPsbt, +) -> Result<(Vec, Vec, i64), ZcashParseTransactionError> { + if z.require_no_shielded_output_ever_added() + .map_err(ZcashParseTransactionError::Shielded)? + { + return Ok((Vec::new(), Vec::new(), 0)); + } + let info = z + .ironwood_bundle_info() + .map_err(ZcashParseTransactionError::Shielded)?; + let outputs = shielded_parsed_outputs(z, &info.outputs)?; + let spends = info + .spends + .into_iter() + .map(|spend| ZcashShieldedSpend { + action_index: spend.action_index, + value: spend.value, + recipient: spend.recipient, + nullifier: spend.nullifier, + rk_replaced: spend.rk_replaced, + signed: spend.signed, + }) + .collect(); + Ok((outputs, spends, info.value_balance)) +} diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood.rs index a4921be285f..550c1a9b53b 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood.rs @@ -2380,21 +2380,147 @@ fn spend_bundle_accounting_skips_dummy_outputs_and_counts_spends() { assert_eq!(spends.len(), 1); assert_eq!(spends[0].value, 100_000_000); - // Transaction parsing folds the shielded spend into the input total, so the unshield's - // fee is the 1_000-zatoshi difference rather than an underflow. + // The Zcash parse folds the shielded spend into the input total, so the unshield's + // fee is the 1_000-zatoshi difference rather than an underflow. The generic parse + // refuses the PSBT outright instead of dropping the shielded side. let psbt = BitGoPsbt::Zcash(z, Network::ZcashTestnet); - let parsed = psbt + let generic_error = psbt .parse_transaction_with_wallet_keys( &wallet_keys, &crate::fixed_script_wallet::ReplayProtection::new(vec![]), &[], ) + .unwrap_err(); + assert_eq!( + crate::error::WasmErrorCode::code(&generic_error), + "ParseTransactionError.UseZcashParse" + ); + + let parsed = + crate::fixed_script_wallet::bitgo_psbt::zcash::parse::parse_transaction_with_wallet_keys( + &psbt, + &wallet_keys, + &crate::fixed_script_wallet::ReplayProtection::new(vec![]), + &[], + ) .unwrap(); assert_eq!(parsed.miner_fee, 1_000); - assert!(parsed - .outputs - .iter() - .all(|output| output.is_shielded != Some(true))); + assert!(parsed.outputs.iter().all(|output| !output.is_shielded)); + assert!(parsed.is_ironwood_v6); + assert_eq!(parsed.value_balance, 100_000_000); + let crate::fixed_script_wallet::bitgo_psbt::zcash::parse::ZcashParsedInputs::Shielded( + shielded_spends, + ) = &parsed.inputs + else { + panic!("a deshield parses as shielded inputs"); + }; + assert_eq!(shielded_spends.len(), 1); + assert_eq!(shielded_spends[0].value, 100_000_000); + assert_eq!(shielded_spends[0].nullifier, spends[0].nullifier); + assert_eq!( + shielded_spends[0].recipient, + test_spend_request().0.recipient + ); + assert!(!shielded_spends[0].rk_replaced); + assert!(!shielded_spends[0].signed); +} + +/// The Zcash parse reports each spend's randomizer and signature state from the PCZT +/// rather than assuming the Constructor's placeholder state. +#[test] +fn zcash_parse_reports_spend_signing_state() { + let (mut z, wallet_keys) = build_unshield_psbt("ironwood_v6_parse_signing_state"); + let parse_spend = |z: &ZcashBitGoPsbt| { + let psbt = BitGoPsbt::Zcash(z.clone(), Network::ZcashTestnet); + let parsed = + crate::fixed_script_wallet::bitgo_psbt::zcash::parse::parse_transaction_with_wallet_keys( + &psbt, + &wallet_keys, + &crate::fixed_script_wallet::ReplayProtection::new(vec![]), + &[], + ) + .unwrap(); + match parsed.inputs { + crate::fixed_script_wallet::bitgo_psbt::zcash::parse::ZcashParsedInputs::Shielded( + mut spends, + ) => spends.remove(0), + _ => panic!("a deshield parses as shielded inputs"), + } + }; + + let (_req, _anchor, ask) = test_spend_request(); + let (rsk, rk) = randomized_spend_key(&ask); + z.replace_ironwood_spend_rk(0, rk).unwrap(); + let spend = parse_spend(&z); + assert!(spend.rk_replaced); + assert!(!spend.signed); + + let sighash = z.v6_sig_digest().unwrap(); + let sig = rsk.sign(OsRng, &sighash); + z.apply_ironwood_spend_signature(0, <[u8; 64]>::from(&sig)) + .unwrap(); + let spend = parse_spend(&z); + assert!(spend.rk_replaced); + assert!(spend.signed); +} + +/// Until the one-input-kind rule lands on the build side, the Zcash parse refuses a PSBT +/// that mixes transparent inputs with shielded spends rather than dropping either side. +#[test] +fn zcash_parse_rejects_mixed_input_kinds() { + let wallet_keys = root_wallet_keys("ironwood_v6_parse_mixed"); + let nu6_3 = NetworkUpgrade::Nu6_3.testnet_activation_height(); + let mut psbt = + BitGoPsbt::new_zcash_v6_at_height(Network::ZcashTestnet, &wallet_keys, nu6_3, None, None) + .unwrap(); + psbt.add_wallet_input( + Txid::from_byte_array([0x33u8; 32]), + 0, + 200_000_000, + &wallet_keys, + ScriptId { chain: 0, index: 0 }, + WalletInputOptions::default(), + ) + .unwrap(); + let BitGoPsbt::Zcash(mut z, network) = psbt else { + panic!("expected Zcash PSBT"); + }; + z.add_transparent_output( + miniscript::bitcoin::ScriptBuf::from(vec![0x76u8, 0xa9, 0x14, 0x88, 0xac]), + 299_999_000, + None, + ) + .unwrap(); + let (req, anchor, _ask) = test_spend_request(); + z.add_ironwood_actions(&[req], &[], &anchor, OsRng).unwrap(); + + let psbt = BitGoPsbt::Zcash(z, network); + let error = + crate::fixed_script_wallet::bitgo_psbt::zcash::parse::parse_transaction_with_wallet_keys( + &psbt, + &wallet_keys, + &crate::fixed_script_wallet::ReplayProtection::new(vec![]), + &[], + ) + .unwrap_err(); + assert_eq!( + crate::error::WasmErrorCode::code(&error), + "ZcashParseTransactionError.MixedInputKinds" + ); +} + +/// Transparent failures in the Zcash parse keep the coin-neutral `ParseTransactionError.*` +/// codes: routing every Zcash PSBT through the Zcash parse changes no existing error code. +#[test] +fn zcash_parse_keeps_transparent_error_codes() { + let error = + crate::fixed_script_wallet::bitgo_psbt::zcash::parse::ZcashParseTransactionError::from( + crate::fixed_script_wallet::bitgo_psbt::ParseTransactionError::FeeCalculation, + ); + assert_eq!( + crate::error::WasmErrorCode::code(&error), + "ParseTransactionError.FeeCalculation" + ); } /// `apply_ironwood_spend_signature` must refuse to ingest a spend-auth signature while a @@ -2464,8 +2590,10 @@ fn shielding_v6_keeps_the_bitcoin_weight_virtual_size() { assert!(z.raw_ironwood_pczt_bytes().is_some()); let weight_vsize = z.psbt.unsigned_tx.weight().to_vbytes_ceil(); - let parsed = BitGoPsbt::Zcash(z, Network::ZcashTestnet) - .parse_transaction_with_wallet_keys( + let psbt = BitGoPsbt::Zcash(z, Network::ZcashTestnet); + let parsed = + crate::fixed_script_wallet::bitgo_psbt::zcash::parse::parse_transaction_with_wallet_keys( + &psbt, &RootWalletKeys::new(get_test_wallet_keys(seed)), &crate::fixed_script_wallet::ReplayProtection::new(vec![]), &[], diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/v6.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/v6.rs index 11d9088a21b..be2ab459018 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/v6.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/v6.rs @@ -876,7 +876,7 @@ pub(crate) fn new_bitgo_psbt_v6_at_height( /// The caller-supplied Unified Address for a transparent output, if `add_transparent_output` /// was given one for `output_index` — `None` for non-Zcash PSBTs or outputs without one. /// -/// Mirrors `fold_shielded_outputs`'s treatment of `add_ironwood_output`'s UA: the +/// Mirrors the Zcash parse's treatment of shielded outputs' stored UAs: the /// caller-supplied UA, not a bare address reconstructed from the scriptPubKey alone, is /// what a client actually pasted in. pub(crate) fn transparent_output_address_override( 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 05d6ffebece..90d4f36c56e 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/mod.rs @@ -847,6 +847,16 @@ impl BitGoPsbt { .map(|ecpair| ecpair.get_public_key()) .collect(); + // A Zcash PSBT routes to the Zcash parse, which surfaces the shielded side. + if let crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::Zcash(..) = &self.psbt { + return zcash_psbt::parse_transaction_with_wallet_keys( + &self.psbt, + wallet_keys, + replay_protection, + &pubkeys, + ); + } + // Call the Rust implementation let parsed_tx = self .psbt @@ -875,6 +885,11 @@ impl BitGoPsbt { .map(|ecpair| ecpair.get_public_key()) .collect(); + // A Zcash PSBT routes to the Zcash parse, which surfaces the shielded side. + if let crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt::Zcash(..) = &self.psbt { + return zcash_psbt::parse_outputs_with_wallet_keys(&self.psbt, wallet_keys, &pubkeys); + } + // Call the Rust implementation let parsed_outputs = self .psbt diff --git a/packages/wasm-utxo/src/wasm/fixed_script_wallet/zcash_psbt.rs b/packages/wasm-utxo/src/wasm/fixed_script_wallet/zcash_psbt.rs index fd057d9612d..16b2a31b3bf 100644 --- a/packages/wasm-utxo/src/wasm/fixed_script_wallet/zcash_psbt.rs +++ b/packages/wasm-utxo/src/wasm/fixed_script_wallet/zcash_psbt.rs @@ -13,11 +13,17 @@ use super::fee_policy_from_js; use super::parse_network; use super::BitGoPsbt; use crate::error::WasmUtxoError; +use crate::fixed_script_wallet::bitgo_psbt::zcash::parse::{ + ZcashParsedInput, ZcashParsedInputs, ZcashParsedOutput, ZcashParsedTransaction, + ZcashShieldedSpend, +}; use crate::fixed_script_wallet::bitgo_psbt::zcash::IronwoodOutputRequest; use crate::wasm::bip32::WasmBIP32; use crate::wasm::ecpair::WasmECPair; use crate::wasm::try_from_js_value::Bytes; use crate::wasm::try_from_js_value::TryFromJsValue; +use crate::wasm::try_into_js_value::js_obj; +use crate::wasm::try_into_js_value::TryIntoJsValue; use crate::wasm::wallet_keys::WasmRootWalletKeys; use crate::zcash::ironwood_build::{ AnchorBytes, MemoBytes, OrchardAddressBytes, OvkBytes, ANCHOR_SIZE, MEMO_SIZE, @@ -690,3 +696,111 @@ impl TryFromJsValue for IronwoodOutputRequest { }) } } + +/// The Zcash parse, exposed to JS: a Zcash PSBT always routes here (the generic parse +/// refuses to drop the shielded side), so the returned object carries the additive +/// `inputKind` / `shieldedSpends` / `valueBalance` fields. +pub(crate) fn parse_transaction_with_wallet_keys( + psbt: &crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt, + wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, + replay_protection: &crate::fixed_script_wallet::ReplayProtection, + paygo_pubkeys: &[miniscript::bitcoin::secp256k1::PublicKey], +) -> Result { + crate::fixed_script_wallet::bitgo_psbt::zcash::parse::parse_transaction_with_wallet_keys( + psbt, + wallet_keys, + replay_protection, + paygo_pubkeys, + )? + .try_to_js_value() +} + +/// The Zcash outputs parse, exposed to JS (see [`parse_transaction_with_wallet_keys`]). +pub(crate) fn parse_outputs_with_wallet_keys( + psbt: &crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt, + wallet_keys: &crate::fixed_script_wallet::RootWalletKeys, + paygo_pubkeys: &[miniscript::bitcoin::secp256k1::PublicKey], +) -> Result { + crate::fixed_script_wallet::bitgo_psbt::zcash::parse::parse_outputs_with_wallet_keys( + psbt, + wallet_keys, + paygo_pubkeys, + )? + .try_to_js_value() +} + +impl TryIntoJsValue for ZcashParsedInput { + fn try_to_js_value(&self) -> Result { + // Same shape as the generic input, plus the additive `isShielded: false`. + let obj = self.base.try_to_js_value()?; + js_sys::Reflect::set(&obj, &"isShielded".into(), &JsValue::FALSE) + .map_err(|_| WasmUtxoError::new("Failed to set object property"))?; + Ok(obj) + } +} + +impl TryIntoJsValue for ZcashParsedOutput { + fn try_to_js_value(&self) -> Result { + // Same shape as the generic output, plus the always-present `isShielded` and the + // action index for shielded outputs (`null` for transparent ones). + let obj = self.base.try_to_js_value()?; + js_sys::Reflect::set( + &obj, + &"isShielded".into(), + &self.is_shielded.try_to_js_value()?, + ) + .map_err(|_| WasmUtxoError::new("Failed to set object property"))?; + let action_index = match self.action_index { + Some(index) => JsValue::from_f64(index as f64), + None => JsValue::NULL, + }; + js_sys::Reflect::set(&obj, &"actionIndex".into(), &action_index) + .map_err(|_| WasmUtxoError::new("Failed to set object property"))?; + Ok(obj) + } +} + +impl TryIntoJsValue for ZcashShieldedSpend { + fn try_to_js_value(&self) -> Result { + // Until plan PR 7 derives the state from `alpha`, `installed` means the signer + // replaced the Constructor's placeholder `rk` (= the wallet `ak`). + let randomizer = if self.rk_replaced { + "installed" + } else { + "placeholder" + }; + js_obj!( + "actionIndex" => self.action_index, + "value" => self.value, + "recipient" => self.recipient.to_vec(), + "nullifier" => self.nullifier.to_vec(), + "randomizer" => randomizer.to_string(), + "signed" => self.signed, + ) + } +} + +impl TryIntoJsValue for ZcashParsedTransaction { + fn try_to_js_value(&self) -> Result { + let (input_kind, inputs, shielded_spends): ( + &str, + Vec, + Vec, + ) = match &self.inputs { + ZcashParsedInputs::Transparent(inputs) => ("transparent", inputs.clone(), Vec::new()), + ZcashParsedInputs::Shielded(spends) => ("shielded", Vec::new(), spends.clone()), + }; + // The Ironwood fields are additive and v6-only: a v4 parse keeps its existing shape. + let ironwood = self.is_ironwood_v6; + js_obj!( + "inputs" => inputs, + "outputs" => self.outputs.clone(), + "spendAmount" => self.spend_amount, + "minerFee" => self.miner_fee, + "virtualSize" => self.virtual_size, + ? "inputKind" => ironwood.then(|| input_kind.to_string()), + ? "shieldedSpends" => ironwood.then_some(shielded_spends), + ? "valueBalance" => ironwood.then_some(self.value_balance) + ) + } +} diff --git a/packages/wasm-utxo/src/wasm/try_into_js_value.rs b/packages/wasm-utxo/src/wasm/try_into_js_value.rs index fa91e80caf1..f83585fbc00 100644 --- a/packages/wasm-utxo/src/wasm/try_into_js_value.rs +++ b/packages/wasm-utxo/src/wasm/try_into_js_value.rs @@ -42,6 +42,8 @@ macro_rules! js_arr { }}; } +pub(crate) use js_obj; + impl From for JsValue { fn from(err: WasmUtxoError) -> Self { let code = err.code(); @@ -157,6 +159,12 @@ impl TryIntoJsValue for u64 { } } +impl TryIntoJsValue for i64 { + fn try_to_js_value(&self) -> Result { + Ok(js_sys::BigInt::from(*self).into()) + } +} + impl TryIntoJsValue for Vec { fn try_to_js_value(&self) -> Result { Ok(js_sys::Uint8Array::from(self.as_slice()).into()) @@ -398,7 +406,6 @@ impl TryIntoJsValue for crate::fixed_script_wallet::bitgo_psbt::ParsedOutput { "scriptId" => self.script_id, "paygo" => self.paygo, "derivationPath" => self.derivation_path.clone(), - ? "isShielded" => self.is_shielded ) } } diff --git a/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts b/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts index 63474e95b30..5bf3c2d05dc 100644 --- a/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts +++ b/packages/wasm-utxo/test/fixedScript/parseTransactionWithWalletKeys.ts @@ -196,6 +196,33 @@ describe("parseTransactionWithWalletKeys", function () { } }); + // The Zcash parse is additive over the generic shape: transparent inputs gain + // `isShielded: false` and outputs gain `actionIndex: null`, while the Ironwood + // top-level fields stay v6-only. + if (networkName === "zec") { + parsed.inputs.forEach((input, i) => { + assert.strictEqual( + (input as { isShielded?: boolean }).isShielded, + false, + `Input ${i} isShielded should be false for a transparent Zcash input`, + ); + }); + parsed.outputs.forEach((output, i) => { + assert.strictEqual( + (output as { actionIndex?: number | null }).actionIndex, + null, + `Output ${i} actionIndex should be null for a transparent Zcash output`, + ); + }); + for (const key of ["inputKind", "shieldedSpends", "valueBalance"]) { + assert.ok(!(key in parsed), `a v4 Zcash parse should not expose ${key}`); + } + } else { + parsed.inputs.forEach((input, i) => { + assert.ok(!("isShielded" in input), `Input ${i} should not expose isShielded`); + }); + } + // Verify spend amount: AcidTest uses other wallet (800) + null wallet (700) = 1500 // Taproot-only coins (e.g. Pearl) have no legacy outputs so spend amount is 0 assert.strictEqual( diff --git a/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts b/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts index b9c05fdf5bf..4da8f251a74 100644 --- a/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts +++ b/packages/wasm-utxo/test/fixedScript/zcashIronwoodPsbt.ts @@ -7,6 +7,8 @@ import { describe, it } from "mocha"; import { ZcashIronwoodBitGoPsbt, zip302NoMemo, + type ZcashIronwoodParsedOutput, + type ZcashIronwoodParsedTransaction, } from "../../js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.js"; import { IRONWOOD_VERSION_GROUP_ID, @@ -194,6 +196,54 @@ describe("ZcashIronwoodBitGoPsbt v6 (Ironwood)", function () { assert.strictEqual(parsed.spendAmount, 100_000_000n); }); + // The Ironwood parse adds `inputKind`/`shieldedSpends`/`valueBalance` at the top level, + // `isShielded: false` on every transparent input and `actionIndex` on every output. + it("returns the Ironwood parse shape for a shielding PSBT", function () { + const psbt = buildShieldPsbt(); + const parsed: ZcashIronwoodParsedTransaction = psbt.parseTransactionWithWalletKeys( + walletKeys, + { replayProtection: { publicKeys: [] } }, + ); + + assert.strictEqual(parsed.inputKind, "transparent"); + assert.deepStrictEqual(parsed.shieldedSpends, []); + // Shielding moves 1 ZEC into the pool: Σv_spend (0) − Σv_out (1 ZEC). + assert.strictEqual(parsed.valueBalance, -100_000_000n); + assert.strictEqual(parsed.inputs.length, 1); + assert.strictEqual(parsed.inputs[0].isShielded, false); + + const shielded = parsed.outputs.filter((o) => o.isShielded); + assert.strictEqual(shielded.length, 1); + assert.strictEqual(typeof shielded[0].actionIndex, "number"); + const transparent = parsed.outputs.filter((o) => !o.isShielded); + assert.ok(transparent.every((o) => o.actionIndex === null)); + + const outputs: ZcashIronwoodParsedOutput[] = psbt.parseOutputsWithWalletKeys(walletKeys); + assert.deepStrictEqual( + outputs.map((o) => o.actionIndex), + parsed.outputs.map((o) => o.actionIndex), + ); + }); + + it("reports a zero value balance for a v6 PSBT with no shielded output yet", function () { + const psbt = ZcashIronwoodBitGoPsbt.createEmpty("zcashTest", walletKeys, { + blockHeight: NU6_3_TESTNET_HEIGHT, + }); + psbt.addWalletInput({ txid: "11".repeat(32), vout: 0, value: 200_000_000n }, walletKeys, { + scriptId: SCRIPT_ID, + signPath: { signer: "user", cosigner: "bitgo" }, + }); + psbt.addWalletOutput(walletKeys, { chain: 1, index: 0, value: 199_900_000n }); + + const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, { + replayProtection: { publicKeys: [] }, + }); + assert.strictEqual(parsed.inputKind, "transparent"); + assert.deepStrictEqual(parsed.shieldedSpends, []); + assert.strictEqual(parsed.valueBalance, 0n); + assert.strictEqual(parsed.outputs[0].actionIndex, null); + }); + // The shielding flow is funded by transparent inputs, so it keeps the transparent-skeleton // fee estimate (`Dimensions.fromPsbt` and `virtualSize`) the indexer's fee-rate checks use: // adding the shielded output does not change either. diff --git a/packages/wasm-utxo/test/types/parsedTypes.ts b/packages/wasm-utxo/test/types/parsedTypes.ts new file mode 100644 index 00000000000..34a655104ef --- /dev/null +++ b/packages/wasm-utxo/test/types/parsedTypes.ts @@ -0,0 +1,104 @@ +/** + * Compile-time type tests for the generic parsed-input type parameter (plan PR 3). + * + * Nothing here runs — these are type-level assertions checked by `tsc`; the exported + * functions exist only to anchor the annotations. A mismatch fails `npm run build:test`. + */ +import { + BitGoPsbt, + type ParsedOutput, + type ParsedTransaction, +} from "../../js/fixedScriptWallet/BitGoPsbt.js"; +import { + ZcashBitGoPsbt, + type ZcashNetworkName, + type ZcashParsedInput, + type ZcashParsedOutput, +} from "../../js/fixedScriptWallet/ZcashBitGoPsbt.js"; +import { + ZcashIronwoodBitGoPsbt, + type ZcashIronwoodParsedOutput, + type ZcashIronwoodParsedTransaction, +} from "../../js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.js"; +import type { ZcashShieldedSpend } from "../../js/fixedScriptWallet/ZcashIronwoodTypes.js"; +import type { WalletKeysArg } from "../../js/fixedScriptWallet/RootWalletKeys.js"; + +/** A coin-specific output type a caller might plug into `BitGoPsbt`. */ +type CustomOutput = ParsedOutput & { extra: boolean }; + +// Existing `BitGoPsbt` / `ParsedTransaction` usages keep compiling (TInput defaults). +type ExistingTransaction = ParsedTransaction; +const existingTx: ExistingTransaction = { + inputs: [], + outputs: [ + { + address: null, + script: new Uint8Array(), + value: 1n, + scriptId: null, + paygo: false, + derivationPath: null, + extra: true, + }, + ], + spendAmount: 1n, + minerFee: 0n, + virtualSize: 1, +}; +// A `TInput` narrower than `ParsedInput` flows through the second parameter. +type CustomTransaction = ParsedTransaction; +const customInput: ZcashParsedInput = { + previousOutput: { txid: "", vout: 0 }, + address: "", + script: new Uint8Array(), + value: 1n, + scriptId: null, + scriptType: "p2sh", + sequence: 0, + derivationPath: null, + isShielded: false, +}; +const customTx: CustomTransaction = { ...existingTx, inputs: [customInput] }; +void customTx; +// `ZcashIronwoodBitGoPsbt.fromBytes(...)` is assignable to the generic base. +function fromBytesIsAssignable(): BitGoPsbt { + return ZcashIronwoodBitGoPsbt.fromBytes(new Uint8Array(0), "zcashTest" as ZcashNetworkName); +} +void fromBytesIsAssignable; + +// The Ironwood parse narrows on `inputKind`. +declare const ironwoodPsbt: ZcashIronwoodBitGoPsbt; +declare const ironwoodKeys: WalletKeysArg; + +function narrowIronwoodParse(): ZcashIronwoodParsedOutput[] { + const parsed: ZcashIronwoodParsedTransaction = ironwoodPsbt.parseTransactionWithWalletKeys( + ironwoodKeys, + { + replayProtection: { publicKeys: [] }, + }, + ); + const outputs: ZcashIronwoodParsedOutput[] = parsed.outputs; + if (parsed.inputKind === "shielded") { + // `inputs` is exactly `[]` and `shieldedSpends` is a real (non-empty) array type. + const emptyInputs: [] = parsed.inputs; + const spends: ZcashShieldedSpend[] = parsed.shieldedSpends; + void emptyInputs; + void spends; + } else { + // `shieldedSpends` is exactly `[]` on the transparent arm. + const emptySpends: [] = parsed.shieldedSpends; + void emptySpends; + } + return outputs; +} +void narrowIronwoodParse; + +// The Zcash base class keeps its parse surface with narrowed input/output types. +function zcashBaseParse( + psbt: ZcashBitGoPsbt, +): ParsedTransaction { + return psbt.parseTransactionWithWalletKeys(ironwoodKeys, { + replayProtection: { publicKeys: [] }, + }); +} +void zcashBaseParse;