diff --git a/packages/wasm-utxo/cli/src/psbt/add_shielded_input.rs b/packages/wasm-utxo/cli/src/psbt/add_shielded_input.rs index 10462cf35f7..6df29d5ce6e 100644 --- a/packages/wasm-utxo/cli/src/psbt/add_shielded_input.rs +++ b/packages/wasm-utxo/cli/src/psbt/add_shielded_input.rs @@ -146,7 +146,7 @@ pub fn handle_add_shielded_input_command( .collect::>>()?; let map = psbt - .add_shielded_spends(&spends, &outputs, &anchor, OsRng) + .add_shielded_spends(&spends, &outputs, None, &anchor, OsRng) .map_err(|e| anyhow!("failed to add the shielded spends: {e}"))?; // The actions are shuffled, so the later steps' --action-index arguments must come from @@ -154,8 +154,8 @@ pub fn handle_add_shielded_input_command( eprintln!( "{}", serde_json::json!({ - "spendActionIndices": map.spend_action_indices(), - "outputActionIndices": map.output_action_indices(), + "spendActionIndices": map.actions.spend_action_indices(), + "outputActionIndices": map.actions.output_action_indices(), }) ); print_zcash_psbt(&psbt) diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts index d6759e57572..67b52e7ef82 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodBitGoPsbt.ts @@ -74,10 +74,18 @@ export function zip302NoMemo(): Uint8Array { /** * The parsed shape of a Zcash output through the Ironwood parse: the Zcash output shape plus - * the PCZT action index (`null` for transparent outputs). + * the PCZT action index (`null` for transparent outputs) and, for shielded outputs, the + * change marker. */ export type ZcashIronwoodParsedOutput = ZcashParsedOutput & { actionIndex: number | null; + /** + * For a shielded output: whether it pays back to a spent note's own address (see + * `verifyShieldedBundle`'s report); such outputs are excluded from the parse's + * `spendAmount`. Always `false` for transparent outputs, whose wallet ownership is the + * `scriptId` they carry. + */ + isChange: boolean; }; /** @@ -197,7 +205,7 @@ export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt< /** * Parse outputs through the Zcash parse: every output carries `isShielded` and, for - * shielded outputs, its `actionIndex`. + * shielded outputs, its `actionIndex` and `isChange`. */ override parseOutputsWithWalletKeys( walletKeys: WalletKeysArg, @@ -415,15 +423,30 @@ export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt< * the ZIP-302 "no memo" encoding, as {@link addShieldedOutput}'s does * @param options.anchor - the Ironwood note-commitment-tree root every spend's witness * commits to (32 bytes) - * @returns the action index each requested spend/output landed at. The actions are shuffled, - * so request order does not equal action order: any caller correlating per-action data (a - * stored Unified Address, an out-ciphertext) must go through these indices. + * @param options.change - optional `{ amount, spendIndex }`: one more shielded output paying + * `amount` back to `spends[spendIndex]`'s own address — the only shielded change a keyless + * client can recognize (its address is derived from the wallet's spend-authorizing key, so a + * note back to it is spendable only by this wallet). Without it every shielded output is an + * external send. Only an exact address match counts: change to another diversified address + * of the same wallet is reported as an external send. + * @returns the action index each requested spend/output landed at, and the change output's + * action index when requested. With change, `outputActionIndices` has one more entry than + * `outputs`: its last entry is `changeActionIndex`. The actions are shuffled, so request + * order does not equal action order: any caller correlating per-action data (a stored + * Unified Address, an out-ciphertext) must go through these indices. */ addShieldedSpends( spends: ZcashShieldedSpendRequest[], outputs: ZcashShieldedOutputRequest[], - options: { anchor: Uint8Array }, - ): { spendActionIndices: number[]; outputActionIndices: number[] } { + options: { + anchor: Uint8Array; + change?: { amount: bigint; spendIndex: number }; + }, + ): { + spendActionIndices: number[]; + outputActionIndices: number[]; + changeActionIndex: number | null; + } { const map = this.wasm.add_ironwood_shielded_spends( spends.map((spend) => ({ recipient: spend.recipient, @@ -441,10 +464,18 @@ export class ZcashIronwoodBitGoPsbt extends ZcashBitGoPsbt< unifiedAddress: output.unifiedAddress, })), options.anchor, - ) as { spendActionIndices: number[]; outputActionIndices: number[] }; + options.change + ? { amount: options.change.amount, spendIndex: options.change.spendIndex } + : null, + ) as { + spendActionIndices: number[]; + outputActionIndices: number[]; + changeActionIndex: number | null; + }; return { spendActionIndices: Array.from(map.spendActionIndices), outputActionIndices: Array.from(map.outputActionIndices), + changeActionIndex: map.changeActionIndex, }; } diff --git a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodTypes.ts b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodTypes.ts index 5cec43b28bc..59642882fb4 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodTypes.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/ZcashIronwoodTypes.ts @@ -79,12 +79,29 @@ export type ZcashShieldedSpend = { signed: boolean; }; +/** One real shielded output, as surfaced by the verification report. */ +export type ZcashShieldedOutput = { + /** The output's action index in the orchard bundle. */ + actionIndex: number; + /** Output value in zatoshi. */ + value: bigint; + /** Raw Orchard recipient (43 bytes). */ + recipient: Uint8Array; + /** The memo the recipient will decrypt. */ + memo: Uint8Array; + /** + * Whether this output pays back to a real spend's own address — the shielded change a + * keyless client can recognize. Every other output is an external send. + */ + isChange: boolean; +}; + /** The client-side verification report of a shielded bundle. */ 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 }>; + outputs: ZcashShieldedOutput[]; /** The bundle's value balance (Σv_spend − Σv_out). */ valueBalance: bigint; /** The anchor the spends' witnesses commit to. */ diff --git a/packages/wasm-utxo/js/fixedScriptWallet/index.ts b/packages/wasm-utxo/js/fixedScriptWallet/index.ts index 793aa5fe709..717c872c4b2 100644 --- a/packages/wasm-utxo/js/fixedScriptWallet/index.ts +++ b/packages/wasm-utxo/js/fixedScriptWallet/index.ts @@ -66,6 +66,7 @@ export type { IronwoodRandomizerState, ZcashInputKind, ZcashShieldedSpend, + ZcashShieldedOutput, IronwoodVerificationReport, IronwoodShieldedStatus, } from "./ZcashIronwoodTypes.js"; diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/error.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/error.rs index da1c5afc465..889fe60e060 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/error.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/error.rs @@ -22,6 +22,11 @@ pub enum IronwoodPsbtError { /// receiver, or its Orchard receiver is not the output's `recipient`; or a verified /// spend's Orchard receiver could not be encoded as a Unified Address. BadUnifiedAddress(String), + /// The requested shielded change names a spend index outside the request's spends. + BadChangeSpend { + /// The offending spend index. + spend_index: usize, + }, /// The PSBT carries no Ironwood PCZT, or its bundle is not a spend bundle (no real /// spends), and the operation needs one. NotSpendBundle, @@ -116,6 +121,10 @@ impl std::fmt::Display for IronwoodPsbtError { "an Ironwood PCZT is already present; only one call is supported" ), IronwoodPsbtError::BadUnifiedAddress(e) => write!(f, "invalid unified address: {e}"), + IronwoodPsbtError::BadChangeSpend { spend_index } => write!( + f, + "change names spend {spend_index}, which is not among the requested spends" + ), IronwoodPsbtError::NotSpendBundle => { write!(f, "this PSBT carries no Ironwood spend bundle") } diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood_spend.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood_spend.rs index 4c8a75a36d5..e0a7fb459b8 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood_spend.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/ironwood_spend.rs @@ -49,15 +49,21 @@ impl ZcashBitGoPsbt { /// - **one call per PSBT** — a second call would silently overwrite the first batch /// ([`IronwoodPsbtError::AlreadyAdded`]). /// + /// `change`, when given, adds one more shielded output paying `change.amount` back to + /// `spends[change.spend_index]`'s own address — the only shielded change a keyless client + /// can recognize (see [`crate::zcash::ironwood_verify::VerifiedOutput::is_change`]). Its + /// action index is reported as [`IronwoodSpendActionMap::change_action_index`]. + /// /// Also stored: each output's Unified Address, keyed by its action index, and the /// all-clear `out_ciphertext` bitset (proprietary key `0x09`). pub fn add_shielded_spends( &mut self, spends: &[crate::zcash::ironwood_construct::IronwoodKeylessSpendSpec], outputs: &[IronwoodOutputRequest], + change: Option, anchor: &crate::zcash::ironwood_build::AnchorBytes, rng: R, - ) -> Result { + ) -> Result { use crate::zcash::{ironwood_build, ironwood_construct}; // One input kind: a spend PSBT has no transparent inputs — the shielded side funds itself. @@ -74,6 +80,24 @@ impl ZcashBitGoPsbt { .map_err(IronwoodPsbtError::BadUnifiedAddress)?; } } + // Change goes back to a spent note's own address: append it as the last request. + let mut requests = outputs.to_vec(); + if let Some(change) = change { + let spend = + spends + .get(change.spend_index) + .ok_or(IronwoodPsbtError::BadChangeSpend { + spend_index: change.spend_index, + })?; + requests.push(IronwoodOutputRequest { + recipient: spend.recipient, + amount: change.amount, + ovk: None, + memo: crate::zcash::ironwood_build::ZIP302_NO_MEMO, + unified_address: None, + }); + } + let outputs = requests.as_slice(); let output_specs: Vec = outputs .iter() @@ -96,7 +120,12 @@ impl ZcashBitGoPsbt { &mut self.psbt, vec![0u8; action_count.div_ceil(8)], ); - Ok(action_map) + let change_action_index = + change.and_then(|_| action_map.output_action_index(outputs.len() - 1)); + Ok(IronwoodSpendActionMap { + actions: action_map, + change_action_index, + }) } /// The one-input-kind rule on the transparent side: reject adding a transparent input to a PSBT @@ -687,6 +716,28 @@ impl ZcashBitGoPsbt { } } +/// The change half of [`ZcashBitGoPsbt::add_shielded_spends`]: `amount` zatoshi back to +/// `spends[spend_index]`'s own address. +#[derive(Clone, Copy, Debug)] +pub struct IronwoodSpendChange { + /// Change value in zatoshi (> 0). + pub amount: u64, + /// Which requested spend's address receives the change. + pub spend_index: usize, +} + +/// Where [`ZcashBitGoPsbt::add_shielded_spends`]' requests landed: the requested spends and +/// outputs (the change output, when requested, is the last output entry) plus the change's +/// action index. +#[derive(Clone, Debug)] +pub struct IronwoodSpendActionMap { + /// The action index of every requested spend and output; the change output, if any, is + /// the last entry of `output_action_indices`. + pub actions: crate::zcash::ironwood_build::IronwoodActionMap, + /// The change output's action index, when change was requested. + pub change_action_index: Option, +} + /// Which input kind a v6 PSBT carries. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum IronwoodInputKind { 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 index e713cbda3ad..73cd128594d 100644 --- 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 @@ -52,13 +52,18 @@ pub struct ZcashParsedInput { } /// A Zcash output: the transparent parse shape plus the shielded marker and, for shielded -/// outputs, the PCZT action index. +/// outputs, the PCZT action index and change marker. #[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, + /// For a shielded output: whether it pays back to a spent note's own address (see + /// [`crate::zcash::ironwood_verify::VerifiedOutput::is_change`]); such outputs are + /// excluded from `spend_amount`. Always `false` for transparent outputs, whose wallet + /// ownership is the base parse's `script_id`. + pub is_change: bool, } /// The inputs of a Zcash transaction: transparent, or the shielded spends of a deshield / @@ -235,11 +240,13 @@ pub(crate) fn parse_transaction_with_wallet_keys( index: output_index, }, )?; - spend_amount = spend_amount.checked_add(output.base.value).ok_or( - ParseTransactionError::SpendAmountOverflow { - index: output_index, - }, - )?; + if !output.is_change { + spend_amount = spend_amount.checked_add(output.base.value).ok_or( + ParseTransactionError::SpendAmountOverflow { + index: output_index, + }, + )?; + } outputs.push(output); } @@ -281,32 +288,33 @@ fn transparent_outputs(outputs: Vec) -> Vec { base, is_shielded: false, action_index: None, + is_change: false, }) .collect() } -/// Synthesize a [`ZcashParsedOutput`] for every real shielded output -/// `(action_index, value, recipient)`. +/// Synthesize a [`ZcashParsedOutput`] for every real shielded output the key-free verifier +/// reported. fn shielded_parsed_outputs( z: &ZcashBitGoPsbt, - shielded_outputs: &[(usize, u64, OrchardAddressBytes)], + shielded_outputs: &[crate::zcash::ironwood_verify::VerifiedOutput], ) -> Result, ZcashParseTransactionError> { shielded_outputs .iter() - .map(|(action_index, value, recipient)| { + .map(|output| { // 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()))?, - }; + let address = + match super::propkv::get_ironwood_unified_address(&z.psbt, output.action_index) { + Some(ua) => ua, + None => crate::zcash::unified_address::encode_orchard_receiver( + &output.recipient, + z.network().to_coin_name(), + ) + .map_err(|e| ZcashParseTransactionError::Shielded(e.to_string()))?, + }; Ok(ZcashParsedOutput { base: ParsedOutput { address: Some(address), @@ -314,14 +322,15 @@ fn shielded_parsed_outputs( // 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: output.recipient.to_vec(), + value: output.value, script_id: None, paygo: false, derivation_path: None, }, is_shielded: true, - action_index: Some(*action_index), + action_index: Some(output.action_index), + is_change: output.is_change, }) }) .collect() @@ -347,14 +356,7 @@ fn shielded_side( .ironwood_pczt() .map_err(ZcashParseTransactionError::Shielded)?; let report = z.keyless_verify(&pczt)?; - let outputs = shielded_parsed_outputs( - z, - &report - .outputs - .iter() - .map(|output| (output.action_index, output.value, output.recipient)) - .collect::>(), - )?; + let outputs = shielded_parsed_outputs(z, &report.outputs)?; let spends = report .spends .iter() diff --git a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood_spend.rs b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood_spend.rs index eb6082f4ffc..b6eb2b54020 100644 --- a/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood_spend.rs +++ b/packages/wasm-utxo/src/fixed_script_wallet/bitgo_psbt/zcash/tests/ironwood_spend.rs @@ -6,6 +6,9 @@ use super::*; use crate::bitcoin::hashes::Hash; use crate::bitcoin::Txid; use crate::fixed_script_wallet::bitgo_psbt::psbt_wallet_input::WalletInputOptions; +use crate::fixed_script_wallet::bitgo_psbt::zcash::ironwood_spend::{ + IronwoodSpendActionMap, IronwoodSpendChange, +}; use crate::fixed_script_wallet::bitgo_psbt::BitGoPsbt; use crate::fixed_script_wallet::script_id::ScriptId; use crate::networks::Network; @@ -45,7 +48,7 @@ fn keyless_deshield_psbt( seed: &str, ) -> ( ZcashBitGoPsbt, - IronwoodActionMap, + IronwoodSpendActionMap, [u8; 32], FullViewingKey, SpendAuthorizingKey, @@ -66,7 +69,7 @@ fn keyless_deshield_psbt( ) .unwrap(); let map = z - .add_shielded_spends(&specs[..1], &[], &anchor, OsRng) + .add_shielded_spends(&specs[..1], &[], None, &anchor, OsRng) .unwrap(); ( z, @@ -83,7 +86,7 @@ fn keyless_transfer_psbt( seed: &str, ) -> ( ZcashBitGoPsbt, - IronwoodActionMap, + IronwoodSpendActionMap, [u8; 32], FullViewingKey, SpendAuthorizingKey, @@ -102,7 +105,7 @@ fn keyless_transfer_psbt( wallet_output(&fvk, 101, 90_000_000), ]; let map = z - .add_shielded_spends(&specs, &outputs, &anchor, OsRng) + .add_shielded_spends(&specs, &outputs, None, &anchor, OsRng) .unwrap(); ( z, @@ -234,7 +237,7 @@ fn keyless_deshield_lifecycle_round_trips() { let bytes = z.serialize_v6(); z = ZcashBitGoPsbt::deserialize_v6(&bytes, Network::ZcashTestnet).unwrap(); - let pairs = install_randomizers(&mut z, &map, &ask); + let pairs = install_randomizers(&mut z, &map.actions, &ask); assert_no_ak_or_alpha(&z, &ak, &pairs); let bytes = z.serialize_v6(); z = ZcashBitGoPsbt::deserialize_v6(&bytes, Network::ZcashTestnet).unwrap(); @@ -278,10 +281,10 @@ fn keyless_shielded_transfer_lifecycle_round_trips() { assert_eq!(z.ironwood_bundle_info().unwrap().value_balance, 0); // Ciphertext round: every output's out_ciphertext becomes recoverable under the FVK's ovk. - fvk_out_ciphertexts(&mut z, &map, &fvk); + fvk_out_ciphertexts(&mut z, &map.actions, &fvk); let expected = z.ironwood_action_data().unwrap(); - let pairs = install_randomizers(&mut z, &map, &ask); + let pairs = install_randomizers(&mut z, &map.actions, &ask); install_spend_signatures(&mut z, &pairs, &ask); // Round-trip: the patched ciphertexts and signatures survive the PSBT wire. @@ -331,7 +334,7 @@ fn keyless_surplus_psbt( seed: &str, ) -> ( ZcashBitGoPsbt, - IronwoodActionMap, + IronwoodSpendActionMap, [u8; 32], FullViewingKey, SpendAuthorizingKey, @@ -356,7 +359,7 @@ fn keyless_surplus_psbt( wallet_output(&fvk, 101, 50_000_000), ]; let map = z - .add_shielded_spends(&specs[..1], &outputs, &anchor, OsRng) + .add_shielded_spends(&specs[..1], &outputs, None, &anchor, OsRng) .unwrap(); ( z, @@ -376,8 +379,8 @@ fn keyless_surplus_output_lifecycle_round_trips() { let (mut z, map, _ak, fvk, ask) = keyless_surplus_psbt(seed); assert_eq!(z.ironwood_bundle_info().unwrap().value_balance, 10_000_000); - fvk_out_ciphertexts(&mut z, &map, &fvk); - let pairs = install_randomizers(&mut z, &map, &ask); + fvk_out_ciphertexts(&mut z, &map.actions, &fvk); + let pairs = install_randomizers(&mut z, &map.actions, &ask); install_spend_signatures(&mut z, &pairs, &ask); let raw = z @@ -397,7 +400,7 @@ fn keyless_surplus_output_lifecycle_round_trips() { fn keyless_parse_reports_verified_spends() { let seed = "ironwood_v6_keyless_parse"; let (mut z, map, _ak, _fvk, ask) = keyless_deshield_psbt(seed); - install_randomizers(&mut z, &map, &ask); + install_randomizers(&mut z, &map.actions, &ask); let wallet_keys = root_wallet_keys(seed); let psbt = BitGoPsbt::Zcash(z.clone(), Network::ZcashTestnet); @@ -415,7 +418,10 @@ fn keyless_parse_reports_verified_spends() { panic!("expected the shielded input kind"); }; assert_eq!(spends.len(), 1); - assert_eq!(spends[0].action_index, map.spend_action_index(0).unwrap()); + assert_eq!( + spends[0].action_index, + map.actions.spend_action_index(0).unwrap() + ); assert_eq!(spends[0].value, 100_000_000); assert!(spends[0].rk_replaced, "the randomizer was installed"); assert!(!spends[0].signed, "no signature yet"); @@ -445,7 +451,7 @@ fn add_shielded_spends_rejects_transparent_inputs() { panic!("expected Zcash PSBT"); }; let error = z - .add_shielded_spends(&specs[..1], &[], &anchor, OsRng) + .add_shielded_spends(&specs[..1], &[], None, &anchor, OsRng) .unwrap_err(); assert_eq!( crate::error::WasmErrorCode::code(&error), @@ -538,10 +544,10 @@ fn second_add_shielded_spends_is_rejected() { let BitGoPsbt::Zcash(mut z, _) = psbt else { panic!("expected Zcash PSBT"); }; - z.add_shielded_spends(&specs[..1], &[], &anchor, OsRng) + z.add_shielded_spends(&specs[..1], &[], None, &anchor, OsRng) .unwrap(); let error = z - .add_shielded_spends(&specs[..1], &[], &anchor, OsRng) + .add_shielded_spends(&specs[..1], &[], None, &anchor, OsRng) .unwrap_err(); assert_eq!( crate::error::WasmErrorCode::code(&error), @@ -582,13 +588,13 @@ fn add_shielded_spends_stores_unified_addresses_by_action_index() { output.unified_address = Some(ua.clone()); } let map = z - .add_shielded_spends(&specs, &outputs, &anchor, OsRng) + .add_shielded_spends(&specs, &outputs, None, &anchor, OsRng) .unwrap(); let z = ZcashBitGoPsbt::deserialize_v6(&z.serialize_v6(), Network::ZcashTestnet).unwrap(); let pczt = z.ironwood_pczt().unwrap(); for (j, ua) in uas.iter().enumerate() { - let action_index = map.output_action_index(j).unwrap(); + let action_index = map.actions.output_action_index(j).unwrap(); assert_eq!( crate::fixed_script_wallet::bitgo_psbt::zcash::propkv::get_ironwood_unified_address( &z.psbt, @@ -626,7 +632,7 @@ fn add_shielded_spends_rejects_mismatched_unified_address_atomically() { .unwrap(), ); let error = z - .add_shielded_spends(&specs[..1], &[output], &anchor, OsRng) + .add_shielded_spends(&specs[..1], &[output], None, &anchor, OsRng) .unwrap_err(); assert_eq!( crate::error::WasmErrorCode::code(&error), @@ -639,6 +645,105 @@ fn add_shielded_spends_rejects_mismatched_unified_address_atomically() { ); } +/// The `change` half of `add_shielded_spends`: the change output rides the chosen spend's own +/// address, is reported at its own action index, and verifies as `is_change` — the only +/// shielded change a keyless client can recognize. A change spend index outside the request +/// rejects the whole call. +#[test] +fn add_shielded_spends_routes_change_to_the_spend_address() { + let seed = "ironwood_v6_keyless_change"; + let (specs, anchor, fvk, _ak) = keyless_fixture(); + let mut z = empty_v6_psbt(seed); + let map = z + .add_shielded_spends( + &specs, + &[wallet_output(&fvk, 200, 10_000_000)], + Some(IronwoodSpendChange { + amount: 80_000_000, + spend_index: 1, + }), + &anchor, + OsRng, + ) + .unwrap(); + let change_action_index = map.change_action_index.unwrap(); + assert_eq!( + change_action_index, + *map.actions.output_action_indices().last().unwrap(), + "the change output is the last requested output" + ); + + let report = z.verify_shielded_bundle(None, &[]).unwrap(); + let change = report + .outputs + .iter() + .find(|output| output.action_index == change_action_index) + .unwrap(); + assert!(change.is_change, "change rides a spent note's address"); + assert_eq!(change.value, 80_000_000); + // The report lists spends in (shuffled) action order: find request 1 by its action index. + let spent_from = report + .spends + .iter() + .find(|spend| spend.action_index == map.actions.spend_action_index(1).unwrap()) + .unwrap(); + assert_eq!(spent_from.recipient, change.recipient); + // Every other output is external. + assert!(report + .outputs + .iter() + .filter(|output| output.action_index != change_action_index) + .all(|output| !output.is_change)); + + // The parse keeps the change out of `spend_amount` but still counts it as output value: + // 180M spent − (10M external + 80M change) leaves a 90M fee. + let parsed = + crate::fixed_script_wallet::bitgo_psbt::zcash::parse::parse_transaction_with_wallet_keys( + &BitGoPsbt::Zcash(z.clone(), Network::ZcashTestnet), + &root_wallet_keys(seed), + &crate::fixed_script_wallet::ReplayProtection::new(vec![]), + &[], + ) + .unwrap(); + assert_eq!(parsed.spend_amount, 10_000_000); + assert_eq!(parsed.miner_fee, 90_000_000); + let parsed_change = parsed + .outputs + .iter() + .find(|output| output.action_index == Some(change_action_index)) + .unwrap(); + assert!(parsed_change.is_shielded && parsed_change.is_change); + assert_eq!( + parsed + .outputs + .iter() + .filter(|output| output.is_change) + .count(), + 1 + ); + + // A bad spend index rejects the whole call atomically. + let mut z = empty_v6_psbt(seed); + let before = z.serialize_v6(); + let error = z + .add_shielded_spends( + &specs, + &[], + Some(IronwoodSpendChange { + amount: 1, + spend_index: 7, + }), + &anchor, + OsRng, + ) + .unwrap_err(); + assert_eq!( + crate::error::WasmErrorCode::code(&error), + "IronwoodPsbtError.BadChangeSpend" + ); + assert_eq!(z.serialize_v6(), before); +} + /// Constructor errors wrapped by `IronwoodPsbtError` surface the Constructor's own code /// (`IronwoodBuildError.*`), and leave the PSBT untouched. #[test] @@ -647,18 +752,19 @@ fn add_shielded_spends_surfaces_constructor_error_codes() { let (specs, anchor, fvk, _ak) = keyless_fixture(); let mut z = empty_v6_psbt(seed); let before = z.serialize_v6(); - let mut with_ovk = wallet_output(&fvk, 100, 40_000_000); with_ovk.ovk = Some([3u8; 32]); let error = z - .add_shielded_spends(&specs[..1], &[with_ovk], &anchor, OsRng) + .add_shielded_spends(&specs[..1], &[with_ovk], None, &anchor, OsRng) .unwrap_err(); assert_eq!( crate::error::WasmErrorCode::code(&error), "IronwoodBuildError.OvkNotAllowed" ); - let error = z.add_shielded_spends(&[], &[], &anchor, OsRng).unwrap_err(); + let error = z + .add_shielded_spends(&[], &[], None, &anchor, OsRng) + .unwrap_err(); assert_eq!( crate::error::WasmErrorCode::code(&error), "IronwoodBuildError.NoSpends" @@ -681,7 +787,7 @@ fn keyless_spends_prove_and_verify_locally() { &str, ) -> ( ZcashBitGoPsbt, - IronwoodActionMap, + IronwoodSpendActionMap, [u8; 32], FullViewingKey, SpendAuthorizingKey, @@ -705,8 +811,8 @@ fn keyless_spends_prove_and_verify_locally() { ]; for (seed, build, n_actions) in shapes { let (mut z, map, _ak, fvk, ask) = build(seed); - fvk_out_ciphertexts(&mut z, &map, &fvk); - let pairs = install_randomizers(&mut z, &map, &ask); + fvk_out_ciphertexts(&mut z, &map.actions, &fvk); + let pairs = install_randomizers(&mut z, &map.actions, &ask); install_spend_signatures(&mut z, &pairs, &ask); prove_and_verify_locally(z, &pairs, &fvk, n_actions); } @@ -795,13 +901,14 @@ fn fvk_out_ciphertext(z: &ZcashBitGoPsbt, fvk: &FullViewingKey, action_index: us } /// A 2→1 PSBT: one real output and one fabricated dummy output (on a real spend). -fn keyless_two_to_one_psbt(seed: &str) -> (ZcashBitGoPsbt, IronwoodActionMap, FullViewingKey) { +fn keyless_two_to_one_psbt(seed: &str) -> (ZcashBitGoPsbt, IronwoodSpendActionMap, FullViewingKey) { let (specs, anchor, fvk, _ak) = keyless_fixture(); let mut z = empty_v6_psbt(seed); let map = z .add_shielded_spends( &specs, &[wallet_output(&fvk, 100, 90_000_000)], + None, &anchor, OsRng, ) @@ -841,7 +948,7 @@ fn out_ciphertext_request_lists_real_outputs_and_errors_when_empty() { let (mut z, map, fvk) = keyless_two_to_one_psbt("ironwood_v6_keyless_request"); let (bytes, pending) = z.out_ciphertext_request().unwrap(); assert_eq!(bytes, z.raw_ironwood_pczt_bytes().unwrap()); - assert_eq!(pending, vec![map.output_action_index(0).unwrap()]); + assert_eq!(pending, vec![map.actions.output_action_index(0).unwrap()]); assert_eq!(z.pending_out_ciphertexts().unwrap(), pending); // The ciphertext round resolves the request; a further request errors with NothingPending. @@ -857,7 +964,7 @@ fn out_ciphertext_request_lists_real_outputs_and_errors_when_empty() { let (specs, anchor, _fvk, _ak) = keyless_fixture(); let mut spend_only = empty_v6_psbt("ironwood_v6_keyless_request_spend_only"); spend_only - .add_shielded_spends(&specs[..1], &[], &anchor, OsRng) + .add_shielded_spends(&specs[..1], &[], None, &anchor, OsRng) .unwrap(); assert!(matches!( spend_only.out_ciphertext_request(), @@ -887,7 +994,7 @@ fn out_ciphertext_request_lists_real_outputs_and_errors_when_empty() { fn add_out_ciphertexts_is_atomic() { let (mut z, map, fvk) = keyless_two_to_one_psbt("ironwood_v6_keyless_atomic"); let before = z.raw_ironwood_pczt_bytes().unwrap(); - let real_index = map.output_action_index(0).unwrap(); + let real_index = map.actions.output_action_index(0).unwrap(); let ct = fvk_out_ciphertext(&z, &fvk, real_index); let error = z @@ -914,7 +1021,7 @@ fn add_out_ciphertexts_is_atomic() { #[test] fn add_out_ciphertexts_is_idempotent_for_identical_bytes_only() { let (mut z, map, fvk) = keyless_two_to_one_psbt("ironwood_v6_keyless_idempotent"); - let action_index = map.output_action_index(0).unwrap(); + let action_index = map.actions.output_action_index(0).unwrap(); let ct = fvk_out_ciphertext(&z, &fvk, action_index); z.add_out_ciphertexts(&[(action_index, ct)]).unwrap(); @@ -941,7 +1048,7 @@ fn add_out_ciphertexts_is_idempotent_for_identical_bytes_only() { #[test] fn add_out_ciphertexts_rejects_conflicting_duplicates_in_one_batch() { let (mut z, map, fvk) = keyless_two_to_one_psbt("ironwood_v6_keyless_batch_duplicates"); - let action_index = map.output_action_index(0).unwrap(); + let action_index = map.actions.output_action_index(0).unwrap(); let ct = fvk_out_ciphertext(&z, &fvk, action_index); let before = z.raw_ironwood_pczt_bytes().unwrap(); @@ -973,11 +1080,11 @@ fn add_out_ciphertexts_rejects_conflicting_duplicates_in_one_batch() { fn missing_out_ciphertexts_marker_fails_closed() { let seed = "ironwood_v6_keyless_marker_stripped"; let (mut z, map, _ak, fvk, ask) = keyless_transfer_psbt(seed); - fvk_out_ciphertexts(&mut z, &map, &fvk); + fvk_out_ciphertexts(&mut z, &map.actions, &fvk); assert!(z.pending_out_ciphertexts().unwrap().is_empty()); strip_out_ciphertexts_marker(&mut z); - let mut expected = map.output_action_indices().to_vec(); + let mut expected = map.actions.output_action_indices().to_vec(); expected.sort_unstable(); assert_eq!(z.pending_out_ciphertexts().unwrap(), expected); assert!(matches!( @@ -992,13 +1099,14 @@ fn missing_out_ciphertexts_marker_fails_closed() { // Re-delivering (a fresh response would carry fresh randomness) resolves it. let entries: Vec<_> = map + .actions .output_action_indices() .iter() .map(|&action_index| (action_index, fvk_out_ciphertext(&z, &fvk, action_index))) .collect(); z.add_out_ciphertexts(&entries).unwrap(); assert!(z.pending_out_ciphertexts().unwrap().is_empty()); - let pairs = install_randomizers(&mut z, &map, &ask); + let pairs = install_randomizers(&mut z, &map.actions, &ask); install_spend_signatures(&mut z, &pairs, &ask); z.proof_request(&pairs).unwrap(); } @@ -1010,11 +1118,12 @@ fn add_out_ciphertexts_rejects_after_a_spend_signature() { let seed = "ironwood_v6_keyless_rule3_spend_sig"; let (mut z, map, _ak, fvk, ask) = keyless_transfer_psbt(seed); - fvk_out_ciphertexts(&mut z, &map, &fvk); - let pairs = install_randomizers(&mut z, &map, &ask); + fvk_out_ciphertexts(&mut z, &map.actions, &fvk); + let pairs = install_randomizers(&mut z, &map.actions, &ask); install_spend_signatures(&mut z, &pairs, &ask); let entries: Vec<(usize, [u8; 80])> = map + .actions .output_action_indices() .iter() .map(|&action_index| (action_index, [0u8; 80])) @@ -1056,22 +1165,22 @@ fn proof_request_requires_ciphertexts_randomizers_then_signatures() { let seed = "ironwood_v6_keyless_proof_request"; let (mut z, map, _ak, fvk, ask) = keyless_transfer_psbt(seed); - let mut outputs = map.output_action_indices().to_vec(); + let mut outputs = map.actions.output_action_indices().to_vec(); outputs.sort_unstable(); assert!(matches!( z.proof_request(&[]), Err(IronwoodPsbtError::OutCiphertextPending { action_indices }) if action_indices == outputs )); - fvk_out_ciphertexts(&mut z, &map, &fvk); - let mut spends = map.spend_action_indices().to_vec(); + fvk_out_ciphertexts(&mut z, &map.actions, &fvk); + let mut spends = map.actions.spend_action_indices().to_vec(); spends.sort_unstable(); assert!(matches!( z.proof_request(&[]), Err(IronwoodPsbtError::RandomizerPending { action_indices }) if action_indices == spends )); - let pairs = install_randomizers(&mut z, &map, &ask); + let pairs = install_randomizers(&mut z, &map.actions, &ask); assert!(matches!( z.proof_request(&pairs), Err(IronwoodPsbtError::SpendSignaturePending { action_indices }) if action_indices == spends @@ -1090,7 +1199,7 @@ fn proof_request_requires_ciphertexts_randomizers_then_signatures() { Err(IronwoodPsbtError::DuplicateAction { .. }) )); assert!(matches!( - z.proof_request(&[(map.output_action_indices().len() + 7, [1u8; 32])]), + z.proof_request(&[(map.actions.output_action_indices().len() + 7, [1u8; 32])]), Err(IronwoodPsbtError::UnknownAction) )); assert!(matches!( @@ -1132,7 +1241,7 @@ fn proof_request_requires_ciphertexts_randomizers_then_signatures() { fn pending_out_ciphertexts_gate_sighashes_randomizers_and_signatures() { let seed = "ironwood_v6_keyless_rule1_gates"; let (mut z, map, _ak, _fvk, _ask) = keyless_transfer_psbt(seed); - let spend_index = map.spend_action_indices()[0]; + let spend_index = map.actions.spend_action_indices()[0]; let is_ciphertext_pending = |e: &str| e.contains("pending their out_ciphertext"); let error = z.v6_sig_digest().unwrap_err(); @@ -1164,8 +1273,8 @@ fn sign_then_prove_lifecycle_combines_the_requested_proof() { let seed = "ironwood_v6_keyless_sign_then_prove"; let (mut z, map, _ak, fvk, ask) = keyless_transfer_psbt(seed); - fvk_out_ciphertexts(&mut z, &map, &fvk); - let pairs = install_randomizers(&mut z, &map, &ask); + fvk_out_ciphertexts(&mut z, &map.actions, &fvk); + let pairs = install_randomizers(&mut z, &map.actions, &ask); install_spend_signatures(&mut z, &pairs, &ask); let sighash = z.v6_sig_digest().unwrap(); @@ -1216,8 +1325,11 @@ fn add_spend_randomizers_is_atomic_and_checks_the_pair_against_the_callers_ak() let seed = "ironwood_v6_keyless_randomizer_batch"; let (mut z, map, ak, fvk, ask) = keyless_transfer_psbt(seed); - fvk_out_ciphertexts(&mut z, &map, &fvk); - let [first, second] = [map.spend_action_indices()[0], map.spend_action_indices()[1]]; + fvk_out_ciphertexts(&mut z, &map.actions, &fvk); + let [first, second] = [ + map.actions.spend_action_indices()[0], + map.actions.spend_action_indices()[1], + ]; let pair = |action_index: usize| { let alpha = pasta_curves::pallas::Scalar::random(&mut OsRng); let rk = <[u8; 32]>::from(VerificationKey::::from(&ask.randomize(&alpha))); @@ -1289,9 +1401,9 @@ fn add_spend_randomizers_is_atomic_and_checks_the_pair_against_the_callers_ak() fn add_spend_randomizers_rejects_dummy_spends_and_shielding_bundles() { let seed = "ironwood_v6_keyless_randomizer_targets"; let (mut z, map, ak, fvk, _ask) = keyless_surplus_psbt(seed); - fvk_out_ciphertexts(&mut z, &map, &fvk); + fvk_out_ciphertexts(&mut z, &map.actions, &fvk); let dummy = (0..2) - .find(|i| !map.spend_action_indices().contains(i)) + .find(|i| !map.actions.spend_action_indices().contains(i)) .unwrap(); assert!(matches!( z.add_spend_randomizers(&[(dummy, [1u8; 32], [0u8; 32])], Some(&ak)), @@ -1317,8 +1429,8 @@ fn add_spend_signatures_is_atomic() { let seed = "ironwood_v6_keyless_signature_batch"; let (mut z, map, _ak, fvk, ask) = keyless_transfer_psbt(seed); - fvk_out_ciphertexts(&mut z, &map, &fvk); - let pairs = install_randomizers(&mut z, &map, &ask); + fvk_out_ciphertexts(&mut z, &map.actions, &fvk); + let pairs = install_randomizers(&mut z, &map.actions, &ask); let sighash = z.spend_sighash().unwrap(); let sign = |&(action_index, alpha): &(usize, [u8; 32])| { let alpha = pasta_curves::pallas::Scalar::from_repr(alpha).unwrap(); @@ -1369,9 +1481,9 @@ fn add_spend_signatures_is_atomic() { fn combine_rejects_an_unsigned_spend_with_a_typed_error() { let seed = "ironwood_v6_keyless_combine_unsigned"; let (mut z, map, _ak, fvk, ask) = keyless_transfer_psbt(seed); - fvk_out_ciphertexts(&mut z, &map, &fvk); - install_randomizers(&mut z, &map, &ask); - let mut spends = map.spend_action_indices().to_vec(); + fvk_out_ciphertexts(&mut z, &map.actions, &fvk); + install_randomizers(&mut z, &map.actions, &ask); + let mut spends = map.actions.spend_action_indices().to_vec(); spends.sort_unstable(); assert!(matches!( z.combine_ironwood_proof(patterned_proof(2), OsRng), @@ -1393,8 +1505,8 @@ fn shielded_status_tracks_every_stage() { indices }; let (spends, outputs) = ( - sorted(map.spend_action_indices()), - sorted(map.output_action_indices()), + sorted(map.actions.spend_action_indices()), + sorted(map.actions.output_action_indices()), ); let status = z.shielded_status().unwrap(); @@ -1404,12 +1516,12 @@ fn shielded_status_tracks_every_stage() { assert_eq!(status.pending_spend_signatures, spends); assert!(!status.ready_for_proof); - fvk_out_ciphertexts(&mut z, &map, &fvk); + fvk_out_ciphertexts(&mut z, &map.actions, &fvk); let status = z.shielded_status().unwrap(); assert!(status.pending_out_ciphertexts.is_empty()); assert!(!status.ready_for_proof); - let pairs = install_randomizers(&mut z, &map, &ask); + let pairs = install_randomizers(&mut z, &map.actions, &ask); let status = z.shielded_status().unwrap(); assert!(status.pending_randomizers.is_empty()); assert_eq!(status.pending_spend_signatures, spends); @@ -1453,7 +1565,7 @@ fn shielded_status_tracks_every_stage() { fn spend_fixture_json( name: &str, mut z: ZcashBitGoPsbt, - map: IronwoodActionMap, + map: IronwoodSpendActionMap, anchor: [u8; 32], fvk: &FullViewingKey, ak: [u8; 32], @@ -1495,6 +1607,7 @@ fn spend_fixture_json( // The FROST stand-in: one (alpha, rk = ak.randomize(alpha)) pair per real spend. Only // `rk` is stored; the alphas ride along out of band for the proof request. let randomizers: Vec<(usize, [u8; 32], [u8; 32])> = map + .actions .spend_action_indices() .iter() .map(|&action_index| { @@ -1569,8 +1682,8 @@ fn spend_fixture_json( })), }, "psbt": base64::engine::general_purpose::STANDARD.encode(&psbt_bytes), - "spendActionIndices": map.spend_action_indices(), - "outputActionIndices": map.output_action_indices(), + "spendActionIndices": map.actions.spend_action_indices(), + "outputActionIndices": map.actions.output_action_indices(), "ak": to_hex(&ak), "outCiphertexts": out_ciphertexts.iter().map(|&(action_index, out_ciphertext)| serde_json::json!({ @@ -1648,6 +1761,7 @@ fn generate_ts_spend_fixtures() { .add_shielded_spends( specs_used, &outputs, + None, &anchor, StdRng::seed_from_u64(0xC511_0000 + seed as u64), ) @@ -1694,8 +1808,8 @@ fn sorted_indices(indices: &[usize]) -> Vec { fn verify_shielded_bundle_tracks_the_lifecycle() { let seed = "ironwood_v6_verify_transfer"; let (mut z, map, ak, fvk, ask) = keyless_transfer_psbt(seed); - let spend_indices = sorted_indices(map.spend_action_indices()); - let output_indices = sorted_indices(map.output_action_indices()); + let spend_indices = sorted_indices(map.actions.spend_action_indices()); + let output_indices = sorted_indices(map.actions.output_action_indices()); // Mid-flow, placeholder rks: the caller's ak alone checks nothing (nothing is installed), // and the report shows every spend unrandomized and unsigned. @@ -1738,9 +1852,9 @@ fn verify_shielded_bundle_tracks_the_lifecycle() { // The real outputs take their ciphertexts before any randomizer is installed. The // installed triples are then checked against a copy whose rks are still placeholders: an // expectation aimed at a spend whose rk is not installed yet is a mismatch, not a pass. - fvk_out_ciphertexts(&mut z, &map, &fvk); + fvk_out_ciphertexts(&mut z, &map.actions, &fvk); let placeholder = z.clone(); - let installed = install_randomizer_triples(&mut z, &map, &ask, &ak); + let installed = install_randomizer_triples(&mut z, &map.actions, &ask, &ak); let error = placeholder .verify_shielded_bundle(Some(&ak), &installed) .unwrap_err(); @@ -1821,7 +1935,7 @@ fn verify_shielded_bundle_tracks_the_lifecycle() { fn verify_shielded_bundle_reports_the_deshield() { let seed = "ironwood_v6_verify_deshield"; let (mut z, map, ak, _fvk, ask) = keyless_deshield_psbt(seed); - let triples = install_randomizer_triples(&mut z, &map, &ask, &ak); + let triples = install_randomizer_triples(&mut z, &map.actions, &ask, &ak); install_spend_signatures( &mut z, &triples @@ -1848,10 +1962,10 @@ fn verify_shielded_bundle_reports_the_deshield() { fn verify_shielded_bundle_rejects_a_tampered_bundle() { let seed = "ironwood_v6_verify_tampered"; let (mut z, map, ak, fvk, ask) = keyless_transfer_psbt(seed); - fvk_out_ciphertexts(&mut z, &map, &fvk); - let triples = install_randomizer_triples(&mut z, &map, &ask, &ak); + fvk_out_ciphertexts(&mut z, &map.actions, &fvk); + let triples = install_randomizer_triples(&mut z, &map.actions, &ask, &ak); - let action_index = map.output_action_indices()[0]; + let action_index = map.actions.output_action_indices()[0]; let bytes = crate::fixed_script_wallet::bitgo_psbt::zcash::propkv::get_ironwood_pczt(&z.psbt).unwrap(); let other_recipient = fvk @@ -1880,10 +1994,10 @@ fn verify_shielded_bundle_rejects_a_tampered_bundle() { fn verify_shielded_bundle_rejects_a_randomizer_aimed_at_a_dummy() { let seed = "ironwood_v6_verify_dummy"; let (mut z, map, ak, fvk, ask) = keyless_surplus_psbt(seed); - fvk_out_ciphertexts(&mut z, &map, &fvk); - let triples = install_randomizer_triples(&mut z, &map, &ask, &ak); + fvk_out_ciphertexts(&mut z, &map.actions, &fvk); + let triples = install_randomizer_triples(&mut z, &map.actions, &ask, &ak); let dummy_action = (0..2) - .find(|action| !map.spend_action_indices().contains(action)) + .find(|action| !map.actions.spend_action_indices().contains(action)) .unwrap(); let alpha = [7u8; 32]; let aimed_at_dummy = [(dummy_action, alpha, [8u8; 32])]; diff --git a/packages/wasm-utxo/src/inspect/zcash.rs b/packages/wasm-utxo/src/inspect/zcash.rs index fb126b62640..555ee9544a1 100644 --- a/packages/wasm-utxo/src/inspect/zcash.rs +++ b/packages/wasm-utxo/src/inspect/zcash.rs @@ -552,10 +552,10 @@ mod ironwood_v6_tests { }) .collect(); let map = z - .add_shielded_spends(&specs, &outputs, &anchor, OsRng) + .add_shielded_spends(&specs, &outputs, None, &anchor, OsRng) .unwrap(); let sk = Option::::from(SpendingKey::from_bytes([7u8; 32])).unwrap(); - (z, map, sk) + (z, map.actions, sk) } fn string_value<'a>(node: &'a Node, label: &str) -> &'a str { 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 5439bdd2211..c26626896ad 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 @@ -15,7 +15,8 @@ use super::BitGoPsbt; use crate::error::WasmUtxoError; use crate::fixed_script_wallet::bitgo_psbt::zcash::error::IronwoodPsbtError; use crate::fixed_script_wallet::bitgo_psbt::zcash::ironwood_spend::{ - IronwoodInputKind, IronwoodShieldedStatus, IronwoodVerifiedBundle, + IronwoodInputKind, IronwoodShieldedStatus, IronwoodSpendActionMap, IronwoodSpendChange, + IronwoodVerifiedBundle, }; use crate::fixed_script_wallet::bitgo_psbt::zcash::parse::{ ZcashParsedInput, ZcashParsedInputs, ZcashParsedOutput, ZcashParsedTransaction, @@ -31,8 +32,8 @@ 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, IronwoodActionMap, IronwoodWitness, MemoBytes, OrchardAddressBytes, OvkBytes, - WitnessAuthPath, IRONWOOD_MERKLE_DEPTH, + AnchorBytes, IronwoodWitness, MemoBytes, OrchardAddressBytes, OvkBytes, WitnessAuthPath, + IRONWOOD_MERKLE_DEPTH, }; use crate::zcash::ironwood_construct::IronwoodKeylessSpendSpec; use crate::zcash::ironwood_verify::VerifiedOutput; @@ -648,6 +649,9 @@ impl BitGoPsbt { /// belongs to the viewing-key holder in the spend flow) and its amount must be > 0 /// * `anchor` - the Ironwood note-commitment-tree root every spend's witness commits to /// (32 bytes) + /// * `change` - optional `{ amount: bigint, spendIndex: number }`: one more shielded + /// output paying `amount` back to `spends[spendIndex]`'s own address — the shielded + /// change a keyless client recognizes /// /// One input kind: rejected when the PSBT already carries transparent inputs, and one /// call per PSBT. Every real spend carries the placeholder `rk` and no `alpha`; the @@ -655,15 +659,17 @@ impl BitGoPsbt { /// sighash is computed. Every output's `out_ciphertext` is a random placeholder, /// replaced under the FVK's `ovk`. No key material is accepted or stored. /// - /// Returns `{ spendActionIndices: number[], outputActionIndices: number[] }` — the action - /// index each requested spend/output landed at. The actions are shuffled, so request - /// order does not equal action order: any caller correlating per-action data (a stored - /// Unified Address, an out-ciphertext) must go through these indices. + /// Returns `{ spendActionIndices: number[], outputActionIndices: number[], + /// changeActionIndex: number | null }` — the action index each requested spend/output + /// (and the change) landed at. The actions are shuffled, so request order does not + /// equal action order: any caller correlating per-action data (a stored Unified + /// Address, an out-ciphertext) must go through these indices. pub fn add_ironwood_shielded_spends( &mut self, spends: JsValue, outputs: JsValue, anchor: &[u8], + change: JsValue, ) -> Result { let anchor: AnchorBytes = fixed_bytes(anchor, "anchor")?; let spends = js_sys::Array::from(&spends) @@ -674,8 +680,16 @@ impl BitGoPsbt { .iter() .map(|item| IronwoodOutputRequest::try_from_js_value(&item)) .collect::, _>>()?; + let change = if change.is_undefined() || change.is_null() { + None + } else { + Some(IronwoodSpendChange { + amount: get_required_u64_field(&change, "amount")?, + spend_index: get_field::(&change, "spendIndex")? as usize, + }) + }; self.zcash_mut()? - .add_shielded_spends(&spends, &outputs, &anchor, rand::rngs::OsRng) + .add_shielded_spends(&spends, &outputs, change, &anchor, rand::rngs::OsRng) .map_err(WasmUtxoError::from)? .try_to_js_value() } @@ -878,6 +892,8 @@ impl TryIntoJsValue for VerifiedOutput { "actionIndex" => self.action_index, "value" => self.value, "recipient" => self.recipient.to_vec(), + "memo" => self.memo.to_vec(), + "isChange" => self.is_change, ) } } @@ -974,12 +990,19 @@ impl TryFromJsValue for IronwoodKeylessSpendSpec { } } -impl TryIntoJsValue for IronwoodActionMap { +impl TryIntoJsValue for IronwoodSpendActionMap { fn try_to_js_value(&self) -> Result { - js_obj!( - "spendActionIndices" => self.spend_action_indices().to_vec(), - "outputActionIndices" => self.output_action_indices().to_vec(), - ) + let obj = js_obj!( + "spendActionIndices" => self.actions.spend_action_indices().to_vec(), + "outputActionIndices" => self.actions.output_action_indices().to_vec(), + )?; + let change = match self.change_action_index { + Some(index) => JsValue::from_f64(index as f64), + None => JsValue::NULL, + }; + js_sys::Reflect::set(&obj, &"changeActionIndex".into(), &change) + .map_err(|_| WasmUtxoError::new("Failed to set object property"))?; + Ok(obj) } } @@ -1075,6 +1098,12 @@ impl TryIntoJsValue for ZcashParsedOutput { }; js_sys::Reflect::set(&obj, &"actionIndex".into(), &action_index) .map_err(|_| WasmUtxoError::new("Failed to set object property"))?; + js_sys::Reflect::set( + &obj, + &"isChange".into(), + &JsValue::from_bool(self.is_change), + ) + .map_err(|_| WasmUtxoError::new("Failed to set object property"))?; Ok(obj) } } diff --git a/packages/wasm-utxo/src/zcash/ironwood_build.rs b/packages/wasm-utxo/src/zcash/ironwood_build.rs index f9017f0c60c..7e494c8224c 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_build.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_build.rs @@ -59,6 +59,12 @@ pub const ANCHOR_SIZE: usize = 32; pub const OVK_SIZE: usize = 32; /// Length of the ZIP-302 memo field in bytes. pub const MEMO_SIZE: usize = 512; +/// The ZIP-302 "no memo" memo field: the `0xf6` marker followed by zeros. +pub const ZIP302_NO_MEMO: [u8; MEMO_SIZE] = { + let mut memo = [0u8; MEMO_SIZE]; + memo[0] = 0xf6; + memo +}; /// Length of an Orchard/Ironwood full viewing key in bytes (`ak ‖ nk ‖ rivk`). pub const FVK_SIZE: usize = 96; @@ -342,6 +348,10 @@ pub enum IronwoodVerifyCheck { MissingPlaintext, /// A fabricated (dummy) spend carries a non-zero value. DummySpendValue, + /// An output's `ephemeral_key`/`enc_ciphertext` do not carry its plaintext note: the + /// ciphertext does not decrypt under the note's own `esk` and recipient `pk_d` to exactly + /// the shown note, so the recipient could never find it. + EncCiphertext, } /// The full orchard Verifier role's failure modes (see [`verify_ironwood_spends`]). @@ -379,6 +389,7 @@ impl core::fmt::Display for IronwoodVerifyCheck { Self::SpendSignature => "spend signature", Self::MissingPlaintext => "missing plaintext", Self::DummySpendValue => "dummy spend value", + Self::EncCiphertext => "enc ciphertext", }; f.write_str(s) } diff --git a/packages/wasm-utxo/src/zcash/ironwood_construct.rs b/packages/wasm-utxo/src/zcash/ironwood_construct.rs index 37a2c9f3f32..c015ddc3388 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_construct.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_construct.rs @@ -50,7 +50,7 @@ use orchard::Address; use super::ironwood_build::{ build_ironwood_witness, construct_shield_pczt_multi, AnchorBytes, IronwoodActionMap, IronwoodBuildError, IronwoodOutputSpec, IronwoodWitness, MemoBytes, OrchardAddressBytes, - CMX_PROPRIETARY_KEY, MEMO_SIZE, PLACEHOLDER_RK, + CMX_PROPRIETARY_KEY, PLACEHOLDER_RK, ZIP302_NO_MEMO, }; use super::ironwood_pczt::{ActionWire, OutputWire, SpendWire, WitnessWire}; use super::ironwood_verify::{verify_bundle_without_keys, KeylessVerifyContext}; @@ -479,8 +479,6 @@ fn output_wire_from_note( /// A fresh random address (a random `SpendingKey`'s external address at diversifier 0) and the /// ZIP-302 no-memo field — the dummy output's recipient and memo. fn dummy_output(rng: &mut R) -> (OrchardAddressBytes, MemoBytes) { - let mut memo = [0u8; MEMO_SIZE]; - memo[0] = 0xf6; // ZIP-302 "no memo" loop { let mut bytes = [0u8; 32]; rng.fill_bytes(&mut bytes); @@ -488,7 +486,7 @@ fn dummy_output(rng: &mut R) -> (OrchardAddressBytes, MemoBytes) { let fvk = FullViewingKey::from(&sk); return ( fvk.address_at(0u32, Scope::External).to_raw_address_bytes(), - memo, + ZIP302_NO_MEMO, ); } } diff --git a/packages/wasm-utxo/src/zcash/ironwood_pczt.rs b/packages/wasm-utxo/src/zcash/ironwood_pczt.rs index 5070affce11..9e75a6d0d91 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_pczt.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_pczt.rs @@ -512,6 +512,23 @@ pub(crate) fn with_flags_for_test(bytes: &[u8], flags: u8) -> Result, Ir Ok(()) }) } +/// Test-only: decode the wire form of a serialized PCZT, for tamper tests that splice fields +/// across two bundles (the in-bundle helpers cannot do that). +#[cfg(test)] +pub(crate) fn wire_bytes_to_bundle_wire(bytes: &[u8]) -> BundleWire { + let (&version, body) = bytes.split_first().expect("non-empty serialized PCZT"); + assert_eq!(version, FORMAT_VERSION); + postcard::from_bytes(body).expect("a serialized PCZT re-decodes") +} + +/// Test-only: re-encode a wire form produced by [`wire_bytes_to_bundle_wire`]. +#[cfg(test)] +pub(crate) fn bundle_wire_to_wire_bytes(wire: &BundleWire) -> Vec { + let mut out = Vec::with_capacity(1 + 4096); + out.push(FORMAT_VERSION); + out.extend_from_slice(&postcard::to_stdvec(wire).expect("a wire form re-encodes")); + out +} /// Reconstruct an `orchard` PCZT Ironwood bundle from its wire form. pub fn deserialize_pczt(bytes: &[u8]) -> Result { diff --git a/packages/wasm-utxo/src/zcash/ironwood_verify.rs b/packages/wasm-utxo/src/zcash/ironwood_verify.rs index db4bd111605..c3074b759fc 100644 --- a/packages/wasm-utxo/src/zcash/ironwood_verify.rs +++ b/packages/wasm-utxo/src/zcash/ironwood_verify.rs @@ -20,6 +20,9 @@ //! - `Flags`: the bundle's flags match its contents (spends enabled iff a real spend is //! present, outputs iff a real output is, cross-address always enabled); //! - `ZeroValueOutput`: value-0 (dummy) outputs only sit on actions with a real spend; +//! - `EncCiphertext`: every output's `ephemeral_key`/`enc_ciphertext` decrypt, under the note's +//! own `esk` (derived from its plaintext `rseed`) and its recipient's `pk_d`, to exactly the +//! shown note — so the recipient can find it; the decrypted memo is reported; //! - `SpendSignature`: every present spend-auth signature verifies against the spend's `rk` over //! the caller's sighash. //! @@ -94,6 +97,13 @@ pub struct VerifiedOutput { pub value: u64, /// Raw Orchard recipient (43 bytes). pub recipient: OrchardAddressBytes, + /// The memo the recipient will decrypt (`EncCiphertext` checked it rides the output). + pub memo: [u8; 512], + /// Whether this output pays back to a real spend's own address (byte-equal 43-byte + /// recipient). Such a note is spendable only under the same keys as the note it came from, + /// so it is change; every other output — including one to another diversified address of + /// the same wallet — is reported as an external send. + pub is_change: bool, } /// The verified view of a bundle: the real spends and outputs with their plaintext values, @@ -188,6 +198,9 @@ pub fn verify_bundle_without_keys( action_index, value: output_value, recipient: output_recipient, + // `EncCiphertext`: the on-wire ciphertext must carry exactly this note. + memo: verify_enc_ciphertext(action_index, action)?, + is_change: false, }); continue; } @@ -206,6 +219,8 @@ pub fn verify_bundle_without_keys( action_index, value: output_value, recipient: output_recipient, + memo: verify_enc_ciphertext(action_index, action)?, + is_change: false, }); } } @@ -247,6 +262,13 @@ pub fn verify_bundle_without_keys( } } + // Change: an output back to one of the spent notes' own addresses. + for output in &mut outputs { + output.is_change = spends + .iter() + .any(|spend| spend.recipient == output.recipient); + } + // `ValueSum`: the committed value_sum must equal Σ(v_spend − v_out) over exactly the reported // spends and outputs. Everything left out of the report is value-0 (dummy spends and // fabricated outputs are checked above), so the reported values are the whole bundle: @@ -348,6 +370,63 @@ fn action_plaintext( Ok((spend_value, output_value, output_recipient)) } +/// `EncCiphertext`: decrypt the output's `enc_ciphertext` with the note's own ephemeral secret +/// (derived from its plaintext `rseed` and `rho`) against the recipient's `pk_d` — exactly what +/// sender-side recovery does, and needs no key. Recovery also checks the `ephemeral_key` is +/// `[esk]·g_d` and the decrypted note commits to the action's `cmx`; the decrypted note and +/// recipient must then equal the plaintext ones. Returns the decrypted memo. +fn verify_enc_ciphertext( + action_index: usize, + action: &orchard::pczt::Action, +) -> Result<[u8; 512], IronwoodVerifyError> { + use orchard::note::Rho; + use orchard::note_encryption::IronwoodDomain; + use zcash_note_encryption::{try_output_recovery_with_pkd_esk, Domain}; + + let fail = |detail: &str| { + action_error( + action_index, + IronwoodVerifyCheck::EncCiphertext, + detail.to_string(), + ) + }; + let output = action.output(); + let (Some(recipient), Some(value), Some(rseed)) = + (output.recipient(), output.value(), output.rseed()) + else { + return Err(fail("the output is missing its plaintext note")); + }; + // The output note's `rho` is the paired spend's nullifier. + let rho = Option::::from(Rho::from_bytes(&action.spend().nullifier().to_bytes())) + .ok_or_else(|| fail("the paired nullifier is not a valid rho"))?; + let note = Option::::from(Note::from_parts( + *recipient, + *value, + rho, + *rseed, + *output.note_version(), + )) + .ok_or_else(|| fail("the output's plaintext fields do not form a valid note"))?; + let esk = ::derive_esk(¬e) + .ok_or_else(|| fail("no ephemeral secret derives from the note"))?; + let pk_d = ::get_pk_d(¬e); + let (decrypted, to, memo) = try_output_recovery_with_pkd_esk( + &IronwoodDomain::for_pczt_action(action), + pk_d, + esk, + action, + ) + .ok_or_else(|| { + fail("enc_ciphertext does not decrypt to a note under the shown recipient and rseed") + })?; + if decrypted != note || to != *recipient { + return Err(fail( + "enc_ciphertext decrypts to a different note than the shown plaintext", + )); + } + Ok(memo) +} + /// Run the real-spend-only checks (`SpendWitness`, `Rk`, `ViewingKeyPresent`, `SpendSignature`) on /// one action and return its verified data. fn verify_real_spend( @@ -548,10 +627,11 @@ mod tests { IronwoodSpendSpec, OrchardAddressBytes, WitnessAuthPath, ANCHOR_SIZE, }; use crate::zcash::ironwood_pczt::{ - deserialize_pczt, serialize_pczt, with_action_rewrite_for_test, with_flags_for_test, - with_output_recipient_for_test, with_spend_alpha, with_spend_auth_sig, with_spend_fvk, - with_spend_rk, with_spend_value, with_spend_witness_for_test, with_value_sum_for_test, - without_output_recipient_for_test, ActionRewriteForTest, + bundle_wire_to_wire_bytes, deserialize_pczt, serialize_pczt, wire_bytes_to_bundle_wire, + with_action_rewrite_for_test, with_flags_for_test, with_output_recipient_for_test, + with_spend_alpha, with_spend_auth_sig, with_spend_fvk, with_spend_rk, with_spend_value, + with_spend_witness_for_test, with_value_sum_for_test, without_output_recipient_for_test, + ActionRewriteForTest, }; use ff::{Field, PrimeField}; use orchard::{ @@ -822,6 +902,41 @@ mod tests { ); } + /// An `enc_ciphertext` that does not decrypt to the plaintext note fails `EncCiphertext`. + #[test] + fn swapped_enc_ciphertext_fails_enc_ciphertext() { + let (bytes, _anchor, _recipient) = test_bundle(); + // A valid ciphertext of a DIFFERENT note, from a freshly constructed bundle. + let (specs, other_anchor, _fvk, _ak) = + crate::zcash::ironwood_construct::test_util::keyless_fixture(); + let (other, _) = crate::zcash::ironwood_construct::construct_spend_pczt_keyless( + &specs[..1], + &[IronwoodOutputSpec { + recipient: test_recipient(), + amount: 1_000, + ovk: None, + memo: [0u8; 512], + }], + &other_anchor, + OsRng, + ) + .unwrap(); + // Splice `other`'s action-0 ciphertext over action 0 of `bytes` on the wire form: the + // plaintext note, its cmx and the value stay untouched, so only the ciphertext check + // can catch a builder that hands the recipient a note it can never decrypt. + let mut wire = wire_bytes_to_bundle_wire(&bytes); + wire.actions[0].output.enc_ciphertext = + wire_bytes_to_bundle_wire(&serialize_pczt(&other).unwrap()).actions[0] + .output + .enc_ciphertext + .clone(); + let tampered = bundle_wire_to_wire_bytes(&wire); + assert_eq!( + verify_check(&tampered, &ctx()).unwrap_err(), + IronwoodVerifyCheck::EncCiphertext + ); + } + /// An output whose recipient is withheld would otherwise skip `NoteCommitment` and drop out of /// the report while its value still balanced `ValueSum`: funds to a recipient nobody is shown. #[test] diff --git a/packages/wasm-utxo/test/fixedScript/zcashIronwoodSpend.ts b/packages/wasm-utxo/test/fixedScript/zcashIronwoodSpend.ts index a494f360c8d..3c54ddbb8f7 100644 --- a/packages/wasm-utxo/test/fixedScript/zcashIronwoodSpend.ts +++ b/packages/wasm-utxo/test/fixedScript/zcashIronwoodSpend.ts @@ -238,6 +238,81 @@ describe("ZcashIronwoodBitGoPsbt shielded spends", function () { }); }); + describe("shielded change (addShieldedSpends options.change)", function () { + it("routes change to the chosen spend's own address and verifies as isChange", function () { + const fixture = loadFixture("transfer"); + const psbt = ZcashIronwoodBitGoPsbt.createEmpty( + fixture.network, + walletKeys, + createOptions(fixture.blockHeight), + ); + const spends = spendRequests(fixture); + // One external output alongside the change: 180M spent − (89.995M + 80M) = 10.005M fee. + const [external] = outputRequests(fixture); + const { spendActionIndices, outputActionIndices, changeActionIndex } = psbt.addShieldedSpends( + spends, + [external], + { + anchor: hex(fixture.request.anchor), + change: { amount: 80_000_000n, spendIndex: 1 }, + }, + ); + assert.strictEqual(spendActionIndices.length, spends.length); + assert.notStrictEqual(changeActionIndex, null); + // The change output is the extra, last entry of outputActionIndices. + assert.strictEqual(outputActionIndices.length, 2); + assert.strictEqual(outputActionIndices[1], changeActionIndex); + + const report = psbt.verifyShieldedBundle(); + assert.strictEqual(report.outputs.length, 2); + const change = report.outputs.find((output) => output.actionIndex === changeActionIndex); + assert.ok(change); + assert.strictEqual(change.isChange, true); + assert.strictEqual(change.value, 80_000_000n); + assert.strictEqual(change.memo.length, 512); + const spentFrom = report.spends.find((spend) => spend.actionIndex === spendActionIndices[1]); + assert.ok(spentFrom); + assert.deepStrictEqual(Buffer.from(change.recipient), Buffer.from(spentFrom.recipient)); + // The requested output pays an address no spend comes from: it stays external. + const sent = report.outputs.find((output) => output.actionIndex === outputActionIndices[0]); + assert.ok(sent); + assert.strictEqual(sent.isChange, false); + assert.strictEqual(sent.value, external.amount); + assert.deepStrictEqual( + Buffer.from(sent.memo), + Buffer.from(hex(fixture.request.outputs[0].memo)), + ); + + // The parse keeps the change out of spendAmount but still counts it as output value. + const parsed = psbt.parseTransactionWithWalletKeys(walletKeys, { + replayProtection: { publicKeys: [] }, + }); + assert.strictEqual(parsed.spendAmount, external.amount); + assert.strictEqual(parsed.minerFee, 180_000_000n - external.amount - 80_000_000n); + assert.deepStrictEqual( + parsed.outputs.map((output) => [output.actionIndex, output.isChange]), + report.outputs.map((output) => [output.actionIndex, output.isChange]), + ); + }); + + it("rejects change naming a spend index outside the request", function () { + const fixture = loadFixture("deshield"); + const psbt = ZcashIronwoodBitGoPsbt.createEmpty( + fixture.network, + walletKeys, + createOptions(fixture.blockHeight), + ); + assertErrorCode( + () => + psbt.addShieldedSpends(spendRequests(fixture), [], { + anchor: hex(fixture.request.anchor), + change: { amount: 1n, spendIndex: 7 }, + }), + "IronwoodPsbtError.BadChangeSpend", + ); + }); + }); + for (const name of ["deshield", "transfer"]) { describe(`lifecycle: ${name}`, function () { const fixture = loadFixture(name);