perf(zcash): speed up batch processing and show a loading hint
What changed, and why it matters
This commit is a performance and user-experience improvement for Zcash batch transactions on the Keystone 3 hardware wallet. It speeds up processing of multiple Zcash PCZTs by caching decoded viewing keys and spend-authorization keys across the batch, instead of re-deriving them for every transaction. It also adds a loading hint telling users that batch processing may take a few minutes. There is no direct evidence in the commit of a security vulnerability being fixed; the changes appear to be a refactor with added tests and UI feedback.
No immediate security action is required. Treat as a routine performance refactor. Reviewers may want to verify that the new caching paths preserve the existing validation ordering and that the parity tests adequately cover Orchard, Ironwood, and migration-shaped PCZTs. Ensure the `BatchDisplayCache` lifetime and `ZcashCheckedPczt::free` behavior are correct in C/Rust FFI usage.
Security signals we found
Refactors key derivation caching for Zcash batch signing (SpendAuthCache)
Adds BatchCheckContext to cache decoded UFVK and WalletKeys across batch items
Fuses check and parse passes to reduce bundle walks and output trial-decryption
Introduces BatchDisplayCache to carry parsed display rows from check to parse stage
Adds parity tests asserting byte-identical normalization and equivalent display rows
Adds UI loading hint for long-running Zcash batch operations
Evidence from the diff
The change refactors the Zcash cypherpunk PCZT batch flow. It introduces BatchCheckContext to lazily cache the decoded UFVK and derived WalletKeys across batch items, and SpendAuthCache to reuse the spend-authorizing key across batch signing. The check and parse paths are fused so shielded bundles are walked once rather than twice, and display rows are cached in a new BatchDisplayCache attached to ZcashCheckedPczt so the parse FFI no longer re-decrypts outputs. A new check_batch_pczt_with_display API returns normalized bytes, parsed display rows, and an optional migration summary. The UI adds a subtitle to the loading hint for Zcash batch processing. Extensive parity tests are added to ensure the fused path produces byte-identical normalized outputs and equivalent display rows.
Changed components
rust/apps/zcash/src/lib.rsrust/apps/zcash/src/pczt/check.rsrust/apps/zcash/src/pczt/mod.rsrust/apps/zcash/src/pczt/parse.rsrust/apps/zcash/src/pczt/sign.rsrust/rust_c/src/zcash/mod.rsrust/rust_c/src/zcash/structs.rssrc/ui/gui_components/gui_pending_hintbox.csrc/ui/gui_components/gui_pending_hintbox.hsrc/ui/gui_views/multi/cypherpunk/gui_zcash_batch_view.csrc/ui/lv_i18n/data.csvsrc/ui/lv_i18n/lv_i18n.cInspect captured patch +910 / −252
diff --git a/rust/apps/zcash/src/lib.rs b/rust/apps/zcash/src/lib.rs
index 39e02d8..ca53ba8 100644
--- a/rust/apps/zcash/src/lib.rs
+++ b/rust/apps/zcash/src/lib.rs
@@ -15,6 +15,8 @@ use alloc::{
// the non-cypherpunk build so it stays warning-free.
#[cfg(feature = "cypherpunk")]
use alloc::format;
+#[cfg(feature = "cypherpunk")]
+use pczt::sign::SpendAuthCache;
use pczt::structs::ParsedPczt;
#[cfg(feature = "cypherpunk")]
use pczt::structs::{ParsedFrom, ParsedOrchard, ParsedTo};
@@ -93,7 +95,12 @@ pub fn check_pczt_cypherpunk<P: consensus::Parameters>(
/// Checks one PCZT from a batch request, enforcing the batch shielded-action
/// policy, and returns its normalized encoding. See `check_pczt_cypherpunk`
/// for the normalization contract.
-#[cfg(feature = "cypherpunk")]
+///
+/// Test-only parity reference: production checks a batch PCZT through the
+/// display-producing `check_batch_pczt_with_display`, and this independent
+/// check-only composition (whose bytes are byte-identical) is what the parity
+/// tests diff that fused engine against.
+#[cfg(all(test, feature = "cypherpunk"))]
pub fn check_batch_pczt_cypherpunk<P: consensus::Parameters>(
params: &P,
pczt_bytes: &[u8],
@@ -265,35 +272,87 @@ pub fn parse_pczt_cypherpunk<P: consensus::Parameters>(
pczt::parse::parse_pczt_cypherpunk(params, seed_fingerprint, &ufvk, &pczt)
}
-/// Validates a batch PCZT for the selected account and returns its display data.
+/// Caches the decoded UFVK and wallet viewing keys shared by every PCZT in a
+/// batch.
+/// Values are initialized lazily to preserve validation ordering.
#[cfg(feature = "cypherpunk")]
-pub fn check_and_parse_batch_pczt_cypherpunk<P: consensus::Parameters>(
+pub struct BatchCheckContext<'a> {
+ ufvk_text: &'a str,
+ ufvk: core::cell::OnceCell<UnifiedFullViewingKey>,
+ wallet_keys: core::cell::OnceCell<pczt::parse::WalletKeys>,
+}
+
+#[cfg(feature = "cypherpunk")]
+impl<'a> BatchCheckContext<'a> {
+ /// Creates a lazy key cache for one checked batch.
+ pub fn new(ufvk_text: &'a str) -> Self {
+ Self {
+ ufvk_text,
+ ufvk: core::cell::OnceCell::new(),
+ wallet_keys: core::cell::OnceCell::new(),
+ }
+ }
+
+ fn ufvk<P: consensus::Parameters>(&self, params: &P) -> Result<&UnifiedFullViewingKey> {
+ if let Some(ufvk) = self.ufvk.get() {
+ return Ok(ufvk);
+ }
+ let ufvk = UnifiedFullViewingKey::decode(params, self.ufvk_text)
+ .map_err(|e| ZcashError::InvalidDataError(e.to_string()))?;
+ Ok(self.ufvk.get_or_init(|| ufvk))
+ }
+
+ fn wallet_keys<P: consensus::Parameters>(
+ &self,
+ params: &P,
+ ) -> Result<&pczt::parse::WalletKeys> {
+ if let Some(keys) = self.wallet_keys.get() {
+ return Ok(keys);
+ }
+ let keys = pczt::parse::WalletKeys::derive(self.ufvk(params)?)?;
+ Ok(self.wallet_keys.get_or_init(|| keys))
+ }
+}
+
+/// Validates one batch PCZT and returns its normalized transaction and display.
+#[cfg(feature = "cypherpunk")]
+fn check_and_parse_batch_pczt_internal<P: consensus::Parameters>(
params: &P,
pczt_bytes: &[u8],
- ufvk_text: &str,
+ ctx: &BatchCheckContext<'_>,
seed_fingerprint: &[u8; 32],
account_index: u32,
-) -> Result<ParsedPczt> {
+) -> Result<(Pczt, ParsedPczt)> {
let mut pczt = pczt::parse_pczt(pczt_bytes)?;
- // Resolve compact field representations up front so the single-pass check
- // sees complete actions, matching the standalone batch check.
+ // Resolve compact field representations before the single-pass validation.
pczt.resolve_fields().map_err(|e| {
ZcashError::InvalidPczt(alloc::format!("resolve compact PCZT fields: {e:?}"))
})?;
let account_index = zip32::AccountId::try_from(account_index)
.map_err(|_e| ZcashError::InvalidDataError("invalid account index".to_string()))?;
- let ufvk = UnifiedFullViewingKey::decode(params, ufvk_text)
- .map_err(|e| ZcashError::InvalidDataError(e.to_string()))?;
+ let ufvk = ctx.ufvk(params)?;
let xpub = ufvk.transparent().ok_or(ZcashError::InvalidDataError(
"transparent xpub is not present".to_string(),
))?;
-
+ // `validate_supported_pczt` stays ahead of the wallet-key derivation so the
+ // first PCZT keeps the check-only path's error ordering; the context then
+ // caches the derived keys for every later PCZT.
+ pczt::validate_supported_pczt(&pczt)?;
+ let keys = ctx.wallet_keys(params)?;
// Validate shielded actions while collecting their display rows.
- let (checked_shielded, pczt) = pczt::check::check_and_parse_pczt_shielded(
+ let (
+ pczt::check::CheckedShieldedParse {
+ orchard: checked_orchard,
+ ironwood: checked_ironwood,
+ checked_actions,
+ },
+ pczt,
+ ) = pczt::check::check_and_parse_pczt_shielded(
params,
seed_fingerprint,
account_index,
- &ufvk,
+ ufvk,
+ keys,
pczt,
)?;
@@ -306,34 +365,85 @@ pub fn check_and_parse_batch_pczt_cypherpunk<P: consensus::Parameters>(
&pczt,
false,
)?;
-
- // Reuse the PCZT returned by signability validation for display assembly.
- let (signable_actions, pczt) = signable_shielded_actions(
+ // Apply the batch shape and signable action policy to the checked actions.
+ reject_unsupported_batch_pczt(&pczt)?;
+ let signable_actions = signable_actions_from_checked_actions(
params,
- pczt,
+ &checked_actions,
seed_fingerprint,
account_index,
ShieldedActionPolicy::Batch,
)?;
+ // Release cloned derivation paths before building the retained display.
+ drop(checked_actions);
if signable_actions.is_empty() {
- Err(ZcashError::PcztNoMyInputs)
- } else {
- // Assemble the display from the shielded rows collected above.
- pczt::parse::parse_pczt_cypherpunk_with_checked_shielded(
- params,
- seed_fingerprint,
- &pczt,
- checked_shielded.orchard,
- checked_shielded.ironwood,
- )
+ return Err(ZcashError::PcztNoMyInputs);
}
+ // Assemble the display from the shielded rows collected above.
+ let parsed = pczt::parse::parse_pczt_cypherpunk_with_checked_shielded(
+ params,
+ seed_fingerprint,
+ &pczt,
+ checked_orchard,
+ checked_ironwood,
+ )?;
+ Ok((pczt, parsed))
+}
+
+/// Checks one batch PCZT and returns normalized bytes, display rows, and an
+/// optional compact migration classification. Validation and display share one
+/// shielded action pass, while the context reuses viewing keys across the batch.
+/// `None` means the caller must retain the ordinary display for this PCZT.
+#[cfg(feature = "cypherpunk")]
+pub fn check_batch_pczt_with_display<P: consensus::Parameters>(
+ params: &P,
+ pczt_bytes: &[u8],
+ ctx: &BatchCheckContext<'_>,
+ seed_fingerprint: &[u8; 32],
+ account_index: u32,
+) -> Result<(Vec<u8>, ParsedPczt, Option<BatchMigrationTransferSummary>)> {
+ let (pczt, parsed) = check_and_parse_batch_pczt_internal(
+ params,
+ pczt_bytes,
+ ctx,
+ seed_fingerprint,
+ account_index,
+ )?;
+
+ // Classify from the complete display model produced by this check pass.
+ // The check already bound every funded spend to the selected account.
+ let migration_summary = migration_transfer_summary(&parsed);
+
+ let normalized = pczt
+ .serialize()
+ .map_err(|e| ZcashError::InvalidPczt(alloc::format!("serialize normalized PCZT: {e:?}")))?;
+ Ok((normalized, parsed, migration_summary))
+}
+
+/// Checks and parses one batch PCZT using the shared check-time display path.
+#[cfg(feature = "cypherpunk")]
+pub fn check_and_parse_batch_pczt_cypherpunk<P: consensus::Parameters>(
+ params: &P,
+ pczt_bytes: &[u8],
+ ufvk_text: &str,
+ seed_fingerprint: &[u8; 32],
+ account_index: u32,
+) -> Result<ParsedPczt> {
+ let (_, parsed, _) = check_batch_pczt_with_display(
+ params,
+ pczt_bytes,
+ &BatchCheckContext::new(ufvk_text),
+ seed_fingerprint,
+ account_index,
+ )?;
+ Ok(parsed)
}
/// Values for one compact-eligible Orchard-to-Ironwood transfer, in zatoshis.
#[cfg(feature = "cypherpunk")]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
-struct BatchMigrationTransferSummary {
+pub struct BatchMigrationTransferSummary {
input: u64,
output: u64,
fee: u64,
@@ -500,6 +610,20 @@ fn compact_batch_migration_review(items: Vec<ParsedBatchItem>) -> Vec<ParsedPczt
compact
}
+/// Compacts the display rows cached by the batch check without re-parsing any
+/// PCZT. Classification is independent of the original PCZT order.
+#[cfg(feature = "cypherpunk")]
+pub fn compact_checked_batch_migration_review(
+ items: impl IntoIterator<Item = (ParsedPczt, Option<BatchMigrationTransferSummary>)>,
+) -> Vec<ParsedPczt> {
+ compact_batch_migration_review(
+ items
+ .into_iter()
+ .map(|(parsed, migration)| ParsedBatchItem { parsed, migration })
+ .collect(),
+ )
+}
+
/// Parses checked batch PCZTs and compacts eligible Orchard-to-Ironwood
/// self-transfers without relying on PCZT position.
///
@@ -753,6 +877,93 @@ fn reject_unsupported_batch_pczt(pczt: &Pczt) -> Result<()> {
Ok(())
}
+#[cfg(feature = "cypherpunk")]
+#[allow(clippy::too_many_arguments)]
+/// Decides whether one shielded action is signable by (`seed_fingerprint`,
+/// `account_index`) under `policy`. This is the shared predicate behind
+/// [`collect_signable_shielded_actions`] (the direct bundle walk) and
+/// [`signable_actions_from_checked_actions`] (the batch check's retained
+/// actions), so the two can never diverge.
+///
+/// Zero-valued spends do not authorize value and are omitted from the account
+/// policy. The signer still signs any such action whose derivation and `rk`
+/// match a key available from the seed.
+fn signable_action_decision<P: consensus::Parameters>(
+ params: &P,
+ pool: SignableShieldedPool,
+ index: usize,
+ spend_value: Option<u64>,
+ spend_has_dummy_sk: bool,
+ spend_derivation: Option<(&[u8; 32], &[zcash_vendor::zip32::ChildIndex])>,
+ seed_fingerprint: &[u8; 32],
+ account_index: zip32::AccountId,
+ policy: ShieldedActionPolicy,
+) -> core::result::Result<Option<SignableShieldedAction>, ZcashError> {
+ if spend_has_dummy_sk {
+ return Ok(None);
+ }
+
+ let value = spend_value.ok_or_else(|| {
+ ZcashError::InvalidPczt(alloc::format!("missing {} spend value", pool.label()))
+ })?;
+ if value == 0 {
+ return Ok(None);
+ }
+
+ let matched_account = pczt::matching_seed_supported_orchard_account_parts(
+ seed_fingerprint,
+ spend_derivation,
+ params.network_type().coin_type(),
+ pool.shielded_pool(),
+ )?;
+ if matched_account != Some(account_index) {
+ if policy == ShieldedActionPolicy::Batch {
+ return Err(ZcashError::PcztNoMyInputs);
+ }
+ return Ok(None);
+ }
+
+ Ok(Some(SignableShieldedAction { pool, index }))
+}
+
+/// Applies [`collect_signable_shielded_actions`]' decision to the checked
+/// actions retained by the batch validation pass, avoiding another walk of the
+/// in-memory bundles. The actions retain bundle then action order, preserving
+/// error precedence.
+#[cfg(feature = "cypherpunk")]
+fn signable_actions_from_checked_actions<P: consensus::Parameters>(
+ params: &P,
+ checked_actions: &[pczt::check::ShieldedAction],
+ seed_fingerprint: &[u8; 32],
+ account_index: zip32::AccountId,
+ policy: ShieldedActionPolicy,
+) -> Result<Vec<SignableShieldedAction>> {
+ let mut actions = Vec::new();
+ for checked_action in checked_actions {
+ let pool = match checked_action.pool {
+ pczt::ShieldedPool::Orchard => SignableShieldedPool::Orchard,
+ pczt::ShieldedPool::Ironwood => SignableShieldedPool::Ironwood,
+ };
+ if let Some(action) = signable_action_decision(
+ params,
+ pool,
+ checked_action.index,
+ checked_action.spend_value,
+ checked_action.spend_has_dummy_sk,
+ checked_action
+ .spend_derivation
+ .as_ref()
+ .map(|(fingerprint, path)| (fingerprint, path.as_slice())),
+ seed_fingerprint,
+ account_index,
+ policy,
+ )? {
+ actions.push(action);
+ }
+ }
+ Ok(actions)
+}
+
#[cfg(feature = "cypherpunk")]
fn collect_signable_shielded_actions<P: consensus::Parameters>(
params: &P,
@@ -766,36 +977,30 @@ fn collect_signable_shielded_actions<P: consensus::Parameters>(
use zcash_vendor::pczt::roles::verifier::OrchardError;
for (index, action) in bundle.actions().iter().enumerate() {
- if action.spend().dummy_sk().is_some() {
- continue;
- }
-
- let value = action.spend().value().ok_or_else(|| {
- OrchardError::Custom(ZcashError::InvalidPczt(alloc::format!(
- "missing {} spend value",
- pool.label(),
- )))
- })?;
- if value.inner() == 0 {
- continue;
- }
-
- let matches_account = pczt::matching_seed_supported_orchard_account(
+ if let Some(signable) = signable_action_decision(
+ params,
+ pool,
+ index,
+ action.spend().value().map(|value| value.inner()),
+ action.spend().dummy_sk().is_some(),
+ action
+ .spend()
+ .zip32_derivation()
+ .as_ref()
+ .map(|derivation| {
+ (
+ derivation.seed_fingerprint(),
+ derivation.derivation_path().as_slice(),
+ )
+ }),
seed_fingerprint,
- action.spend().zip32_derivation().as_ref(),
- params.network_type().coin_type(),
- pool.shielded_pool(),
+ account_index,
+ policy,
)
.map_err(OrchardError::Custom)?
- == Some(account_index);
- if !matches_account {
- if policy == ShieldedActionPolicy::Batch {
- return Err(OrchardError::Custom(ZcashError::PcztNoMyInputs));
- }
- continue;
+ {
+ actions.push(signable);
}
-
- actions.push(SignableShieldedAction { pool, index });
}
Ok(())
@@ -929,6 +1134,7 @@ pub fn sign_checked_pczt<P: consensus::Parameters>(
seed_fingerprint,
account_index,
ShieldedActionPolicy::Single,
+ &SpendAuthCache::new(),
)
}
@@ -937,7 +1143,10 @@ pub fn sign_checked_pczt<P: consensus::Parameters>(
/// received a spend authorization signature. Batch policy: additionally rejects
/// PCZT shapes the batch flow does not support and requires at least one owned
/// signable shielded action. Parses `checked_pczt` exactly once and returns the
-/// redacted, version-stamped response bytes.
+/// redacted, version-stamped response bytes. Derives keys into a fresh
+/// [`SpendAuthCache`]; batch loops should use
+/// [`sign_checked_batch_pczt_with_cache`] so PCZTs for the selected account
+/// share one cached derivation.
#[cfg(feature = "cypherpunk")]
pub fn sign_checked_batch_pczt<P: consensus::Parameters>(
params: &P,
@@ -945,6 +1154,29 @@ pub fn sign_checked_batch_pczt<P: consensus::Parameters>(
seed: &[u8],
seed_fingerprint: &[u8; 32],
account_index: u32,
+) -> Result<Vec<u8>> {
+ sign_checked_batch_pczt_with_cache(
+ params,
+ checked_pczt,
+ seed,
+ seed_fingerprint,
+ account_index,
+ &SpendAuthCache::new(),
+ )
+}
+
+/// [`sign_checked_batch_pczt`] with a caller-provided [`SpendAuthCache`]. Create
+/// one cache per signing request and pass it to every PCZT using the same
+/// seed. The normal batch path reuses its selected account key, avoiding
+/// repeated ZIP 32 derivation. An account change scrubs and replaces the slot.
+#[cfg(feature = "cypherpunk")]
+pub fn sign_checked_batch_pczt_with_cache<P: consensus::Parameters>(
+ params: &P,
+ checked_pczt: &[u8],
+ seed: &[u8],
+ seed_fingerprint: &[u8; 32],
+ account_index: u32,
+ ask_cache: &SpendAuthCache,
) -> Result<Vec<u8>> {
sign_checked_pczt_with_policy(
params,
@@ -953,10 +1185,12 @@ pub fn sign_checked_batch_pczt<P: consensus::Parameters>(
seed_fingerprint,
account_index,
ShieldedActionPolicy::Batch,
+ ask_cache,
)
}
#[cfg(feature = "cypherpunk")]
+#[allow(clippy::too_many_arguments)]
fn sign_checked_pczt_with_policy<P: consensus::Parameters>(
params: &P,
checked_pczt: &[u8],
@@ -964,6 +1198,7 @@ fn sign_checked_pczt_with_policy<P: consensus::Parameters>(
seed_fingerprint: &[u8; 32],
account_index: u32,
policy: ShieldedActionPolicy,
+ ask_cache: &SpendAuthCache,
) -> Result<Vec<u8>> {
let pczt = pczt::parse_pczt(checked_pczt)?;
let account_index = zip32::AccountId::try_from(account_index)
@@ -973,7 +1208,7 @@ fn sign_checked_pczt_with_policy<P: consensus::Parameters>(
if policy == ShieldedActionPolicy::Batch && signable_actions.is_empty() {
return Err(ZcashError::PcztNoMyInputs);
}
- let signed = pczt::sign::sign_and_redact_pczt(pczt, seed)?;
+ let signed = pczt::sign::sign_and_redact_pczt_with_cache(pczt, seed, ask_cache)?;
let signed = if signable_actions.is_empty() {
signed
} else {
@@ -1277,6 +1512,7 @@ mod tests {
let sample = pczt::test_support::sample_orchard_foreign_change_pczt();
let expected =
"funded Orchard output paired with a zero-value spend does not belong to the selected account";
+ let ctx = BatchCheckContext::new(&sample.ufvk_text);
for result in [
check_pczt_cypherpunk(
@@ -1287,10 +1523,10 @@ mod tests {
0,
)
.map(|_| ()),
- check_and_parse_batch_pczt_cypherpunk(
+ check_batch_pczt_with_display(
&pczt::test_support::Nu6_3Network,
&sample.bytes,
- &sample.ufvk_text,
+ &ctx,
&sample.seed_fingerprint,
0,
)
@@ -1809,10 +2045,10 @@ mod tests {
fn test_batch_check_and_parse_accepts_ironwood_spend() {
let sample = pczt::test_support::sample_ironwood_pczt();
- let parsed = check_and_parse_batch_pczt_cypherpunk(
+ let (_, parsed, _) = check_batch_pczt_with_display(
&pczt::test_support::Nu6_3Network,
&sample.bytes,
- &sample.ufvk_text,
+ &BatchCheckContext::new(&sample.ufvk_text),
&sample.seed_fingerprint,
0,
)
@@ -1820,10 +2056,10 @@ mod tests {
assert!(parsed.get_orchard().is_some() || parsed.get_ironwood().is_some());
assert_eq!(
- check_and_parse_batch_pczt_cypherpunk(
+ check_batch_pczt_with_display(
&pczt::test_support::Nu6_3Network,
&sample.bytes,
- &sample.ufvk_text,
+ &BatchCheckContext::new(&sample.ufvk_text),
&sample.seed_fingerprint,
1,
)
@@ -1836,10 +2072,10 @@ mod tests {
fn test_batch_check_and_parse_accepts_orchard_to_ironwood_migration() {
let sample = pczt::test_support::sample_migration_pczt();
- let parsed = check_and_parse_batch_pczt_cypherpunk(
+ let (_, parsed, _) = check_batch_pczt_with_display(
&pczt::test_support::Nu6_3Network,
&sample.bytes,
- &sample.ufvk_text,
+ &BatchCheckContext::new(&sample.ufvk_text),
&sample.seed_fingerprint,
0,
)
@@ -1857,10 +2093,10 @@ mod tests {
assert_eq!(parsed.get_fee_value(), "0.0002 ZEC");
assert_eq!(
- check_and_parse_batch_pczt_cypherpunk(
+ check_batch_pczt_with_display(
&pczt::test_support::Nu6_3Network,
&sample.bytes,
- &sample.ufvk_text,
+ &BatchCheckContext::new(&sample.ufvk_text),
&sample.seed_fingerprint,
1,
)
@@ -1869,6 +2105,7 @@ mod tests {
);
}
+ // An undecryptable funded output must fail the fused check and display path.
#[test]
fn test_check_rejects_undecryptable_ironwood_output() {
use zcash_vendor::pczt::Pczt;
@@ -1933,10 +2170,10 @@ mod tests {
// Both review paths enforce the same output-recoverability contract.
assert!(
matches!(
- check_and_parse_batch_pczt_cypherpunk(
+ check_batch_pczt_with_display(
&pczt::test_support::Nu6_3Network,
&corrupted,
- &sample.ufvk_text,
+ &BatchCheckContext::new(&sample.ufvk_text),
&sample.seed_fingerprint,
0,
),
@@ -2448,10 +2685,10 @@ mod tests {
// The ordinary review must enforce the same output recovery rule.
assert!(
matches!(
- check_and_parse_batch_pczt_cypherpunk(
+ check_batch_pczt_with_display(
&pczt::test_support::Nu6_3Network,
&corrupted,
- &sample.ufvk_text,
+ &BatchCheckContext::new(&sample.ufvk_text),
&sample.seed_fingerprint,
0,
),
@@ -2602,5 +2839,132 @@ mod tests {
&sample.seed_fingerprint,
0,
));
+ assert_batch_unsupported_sapling_error(check_batch_pczt_with_display(
+ &pczt::test_support::Nu6_3Network,
+ &sample.bytes,
+ &BatchCheckContext::new(&sample.ufvk_text),
+ &sample.seed_fingerprint,
+ 0,
+ ));
+ }
+
+ /// The display-producing check must return byte-identical normalized bytes
+ /// to the reference `check_batch_pczt_cypherpunk` for the same input, across
+ /// an Orchard change tx, an Ironwood tx, and an Orchard->Ironwood migration.
+ #[test]
+ fn test_check_batch_with_display_bytes_match_reference_check() {
+ let samples = [
+ pczt::test_support::sample_orchard_change_pczt(),
+ pczt::test_support::sample_ironwood_pczt(),
+ pczt::test_support::sample_migration_pczt(),
+ ];
+ let ctx = BatchCheckContext::new(&samples[0].ufvk_text);
+ for sample in &samples {
+ let reference = check_batch_pczt_cypherpunk(
+ &pczt::test_support::Nu6_3Network,
+ &sample.bytes,
+ &sample.ufvk_text,
+ &sample.seed_fingerprint,
+ 0,
+ )
+ .unwrap();
+ let (bytes, _parsed, _summary) = check_batch_pczt_with_display(
+ &pczt::test_support::Nu6_3Network,
+ &sample.bytes,
+ &ctx,
+ &sample.seed_fingerprint,
+ 0,
+ )
+ .unwrap();
+ assert_eq!(
+ bytes, reference,
+ "display check bytes must match the reference check"
+ );
+ }
+ }
+
+ /// The display rows produced during the combined check must equal a fresh
+ /// `parse_pczt_cypherpunk` over the normalized bytes (no PartialEq on
+ /// `ParsedPczt`, so compare the derived-`Debug` renderings, which cover every
+ /// display field).
+ #[test]
+ fn test_check_batch_with_display_rows_match_fresh_parse() {
+ for sample in [
+ pczt::test_support::sample_orchard_change_pczt(),
+ pczt::test_support::sample_ironwood_pczt(),
+ pczt::test_support::sample_migration_pczt(),
+ ] {
+ let (bytes, parsed, _summary) = check_batch_pczt_with_display(
+ &pczt::test_support::Nu6_3Network,
+ &sample.bytes,
+ &BatchCheckContext::new(&sample.ufvk_text),
+ &sample.seed_fingerprint,
+ 0,
+ )
+ .unwrap();
+ let reparsed = parse_pczt_cypherpunk(
+ &pczt::test_support::Nu6_3Network,
+ &bytes,
+ &sample.ufvk_text,
+ &sample.seed_fingerprint,
+ )
+ .unwrap();
+ assert_eq!(
+ alloc::format!("{parsed:?}"),
+ alloc::format!("{reparsed:?}"),
+ "display rows must equal a fresh parse of the normalized bytes"
+ );
+ }
+ }
+
+ /// A migration-shaped row retains the values used by the compact summary.
+ #[test]
+ fn test_check_batch_with_display_caches_migration_classification() {
+ let sample = pczt::test_support::sample_migration_pczt();
+ let (_bytes, _parsed, summary) = check_batch_pczt_with_display(
+ &pczt::test_support::Nu6_3Network,
+ &sample.bytes,
+ &BatchCheckContext::new(&sample.ufvk_text),
+ &sample.seed_fingerprint,
+ 0,
+ )
+ .unwrap();
+ let summary = summary.expect("migration transfer should be compact-eligible");
+ assert_eq!(summary.input, 1_010_000);
+ assert_eq!(summary.output, 990_000);
+ assert_eq!(summary.fee, 20_000);
+ }
+
+ /// A non-migration-shaped PCZT (here, a memo-carrying migration PCZT that the
+ /// amounts-only summary cannot render) must still pass the check successfully but
+ /// yield `None` for the aggregate summary, so the caller falls back to the
+ /// display for that PCZT, which shows the memo.
+ #[test]
+ fn test_check_batch_with_display_non_migration_yields_no_summary() {
+ use zcash_vendor::zcash_protocol::memo::MemoBytes;
+
+ let sample = pczt::test_support::sample_migration_pczt_with_output_memo(
+ MemoBytes::from_bytes(b"covert note").expect("memo text fits"),
+ );
+ let (_bytes, parsed, summary) = check_batch_pczt_with_display(
+ &pczt::test_support::Nu6_3Network,
+ &sample.bytes,
+ &BatchCheckContext::new(&sample.ufvk_text),
+ &sample.seed_fingerprint,
+ 0,
+ )
+ .unwrap();
+ assert!(
+ summary.is_none(),
+ "a memo-carrying PCZT must not fold into the aggregate summary"
+ );
+ let shown = parsed
+ .get_ironwood()
+ .expect("migration must show Ironwood outputs")
+ .get_to()
+ .first()
+ .expect("migration must show the real output")
+ .get_memo();
+ assert_eq!(shown.as_deref(), Some("covert note"));
}
}
diff --git a/rust/apps/zcash/src/pczt/check.rs b/rust/apps/zcash/src/pczt/check.rs
index abb9f49..9a5393a 100644
--- a/rust/apps/zcash/src/pczt/check.rs
+++ b/rust/apps/zcash/src/pczt/check.rs
@@ -24,7 +24,9 @@ use zcash_vendor::{
use zcash_vendor::zcash_protocol::consensus::NetworkConstants;
#[cfg(feature = "cypherpunk")]
-use super::structs::ParsedOrchard;
+use super::parse::WalletKeys;
+#[cfg(feature = "cypherpunk")]
+use super::structs::{ParsedOrchard, ParsedTo};
#[cfg(feature = "cypherpunk")]
use super::ShieldedPool;
@@ -47,6 +49,7 @@ pub fn check_pczt_orchard<P: consensus::Parameters>(
pczt: &Pczt,
) -> Result<(), ZcashError> {
super::validate_supported_pczt(pczt)?;
+ let keys = WalletKeys::derive(ufvk)?;
let should_process_ironwood = super::pczt_should_process_ironwood(pczt);
let verifier = Verifier::new(pczt.clone())
.with_orchard(|bundle| {
@@ -55,6 +58,7 @@ pub fn check_pczt_orchard<P: consensus::Parameters>(
seed_fingerprint,
account_index,
ufvk,
+ &keys,
bundle,
ShieldedPool::Orchard,
)
@@ -70,6 +74,7 @@ pub fn check_pczt_orchard<P: consensus::Parameters>(
seed_fingerprint,
account_index,
ufvk,
+ &keys,
bundle,
ShieldedPool::Ironwood,
)
@@ -81,26 +86,46 @@ pub fn check_pczt_orchard<P: consensus::Parameters>(
Ok(())
}
-/// Orchard and Ironwood display rows collected during shielded validation.
+/// Shielded display rows and checked actions produced by one validation pass.
/// A pool is `None` when it contains no displayable actions.
#[cfg(feature = "cypherpunk")]
pub(crate) struct CheckedShieldedParse {
pub(crate) orchard: Option<ParsedOrchard>,
pub(crate) ironwood: Option<ParsedOrchard>,
+ pub(crate) checked_actions: alloc::vec::Vec<ShieldedAction>,
}
-/// Validates the supported shielded bundles and collects their display rows.
-/// Returns the collected Orchard and Ironwood rows together with the PCZT.
+/// Identifies a checked Orchard or Ironwood action and retains the spend fields
+/// needed to evaluate its signability after the remaining PCZT checks.
+#[cfg(feature = "cypherpunk")]
+pub(crate) struct ShieldedAction {
+ pub(crate) pool: ShieldedPool,
+ pub(crate) index: usize,
+ pub(crate) spend_value: Option<u64>,
+ pub(crate) spend_has_dummy_sk: bool,
+ pub(crate) spend_derivation: Option<([u8; 32], alloc::vec::Vec<zip32::ChildIndex>)>,
+}
+
+/// Validates shielded actions while collecting their [`ParsedOrchard`] display
+/// rows and the [`ShieldedAction`] values needed by the later batch policy. The
+/// result feeds [`super::parse::parse_pczt_cypherpunk_with_checked_shielded`]
+/// without another shielded bundle walk.
+///
+/// The caller supplies the batch's [`WalletKeys`] and must run
+/// `validate_supported_pczt` first to preserve validation ordering. The
+/// PCZT owned by the Verifier is returned with the checked actions for later
+/// checks.
#[cfg(feature = "cypherpunk")]
pub(crate) fn check_and_parse_pczt_shielded<P: consensus::Parameters>(
params: &P,
seed_fingerprint: &[u8; 32],
account_index: zip32::AccountId,
ufvk: &UnifiedFullViewingKey,
+ keys: &WalletKeys,
pczt: Pczt,
) -> Result<(CheckedShieldedParse, Pczt), ZcashError> {
- super::validate_supported_pczt(&pczt)?;
let mut parsed_orchard = None;
+ let mut checked_actions = alloc::vec::Vec::new();
let mut parsed_ironwood = None;
let should_process_ironwood = super::pczt_should_process_ironwood(&pczt);
@@ -112,8 +137,10 @@ pub(crate) fn check_and_parse_pczt_shielded<P: consensus::Parameters>(
seed_fingerprint,
account_index,
ufvk,
+ keys,
bundle,
ShieldedPool::Orchard,
+ &mut checked_actions,
)
.map_err(pczt::roles::verifier::OrchardError::Custom)?;
Ok(())
@@ -129,8 +156,10 @@ pub(crate) fn check_and_parse_pczt_shielded<P: consensus::Parameters>(
seed_fingerprint,
account_index,
ufvk,
+ keys,
bundle,
ShieldedPool::Ironwood,
+ &mut checked_actions,
)
.map_err(pczt::roles::verifier::OrchardError::Custom)?;
Ok(())
@@ -145,6 +174,7 @@ pub(crate) fn check_and_parse_pczt_shielded<P: consensus::Parameters>(
CheckedShieldedParse {
orchard: parsed_orchard,
ironwood: parsed_ironwood,
+ checked_actions,
},
verifier.finish(),
))
@@ -362,16 +392,21 @@ fn check_shielded_bundle<P: consensus::Parameters>(
seed_fingerprint: &[u8; 32],
account_index: zip32::AccountId,
ufvk: &UnifiedFullViewingKey,
+ keys: &WalletKeys,
bundle: &orchard::pczt::Bundle,
pool: ShieldedPool,
) -> Result<(), ZcashError> {
let pool_label = pool.label();
+ let fvk = ufvk.orchard().ok_or(ZcashError::InvalidDataError(
+ "orchard fvk is not present".to_string(),
+ ))?;
bundle.actions().iter().try_for_each(|action| {
check_action(
params,
seed_fingerprint,
account_index,
- ufvk,
+ fvk,
+ keys,
action,
bundle.flags(),
pool,
@@ -400,13 +435,16 @@ fn check_shielded_bundle<P: consensus::Parameters>(
/// Validates a shielded bundle while collecting its non-dummy display rows.
/// Returns `None` when the bundle contains no displayable actions.
#[cfg(feature = "cypherpunk")]
+#[allow(clippy::too_many_arguments)]
fn check_and_parse_shielded_bundle<P: consensus::Parameters>(
params: &P,
seed_fingerprint: &[u8; 32],
account_index: zip32::AccountId,
ufvk: &UnifiedFullViewingKey,
+ keys: &WalletKeys,
bundle: &orchard::pczt::Bundle,
pool: ShieldedPool,
+ checked_actions: &mut alloc::vec::Vec<ShieldedAction>,
) -> Result<Option<ParsedOrchard>, ZcashError> {
let pool_label = pool.label();
let fvk = ufvk.orchard().ok_or(ZcashError::InvalidDataError(
@@ -415,44 +453,54 @@ fn check_and_parse_shielded_bundle<P: consensus::Parameters>(
let mut parsed_orchard = ParsedOrchard::new(vec![], vec![]);
// Validate and decode each action in the canonical order.
- bundle.actions().iter().try_for_each(|action| {
- action.verify_cv_net().map_err(|e| {
- ZcashError::InvalidPczt(format!("invalid cv_net in {pool_label} action: {e:?}"))
- })?;
+ bundle
+ .actions()
+ .iter()
+ .enumerate()
+ .try_for_each(|(index, action)| {
+ let parsed_to = check_action(
+ params,
+ seed_fingerprint,
+ account_index,
+ fvk,
+ keys,
+ action,
+ bundle.flags(),
+ pool,
+ )?;
- check_action_spend(
- params,
- seed_fingerprint,
- account_index,
- fvk,
- action.spend(),
- pool,
- )?;
- action
- .output()
- .verify_note_commitment(action.spend())
- .map_err(|e| {
- ZcashError::InvalidPczt(format!("invalid {pool_label} action cmx: {e:?}"))
- })?;
-
- // Add only real spends to the review.
- if let Some(value) = action.spend().value() {
- if value.inner() != 0 {
- let parsed_from =
- super::parse::parse_orchard_spend(seed_fingerprint, action.spend())?;
- parsed_orchard.add_from(parsed_from);
+ // Add only real spends to the review.
+ if let Some(value) = action.spend().value() {
+ if value.inner() != 0 {
+ let parsed_from =
+ super::parse::parse_orchard_spend(seed_fingerprint, action.spend())?;
+ parsed_orchard.add_from(parsed_from);
+ }
}
- }
- // Require every real output to be recoverable for review.
- let parsed_to = super::parse::parse_orchard_output(params, ufvk, action, pool)?;
- check_restricted_zero_value_output(ufvk, action, bundle.flags(), pool)?;
- if !parsed_to.get_is_dummy() {
- parsed_orchard.add_to(parsed_to);
- }
+ if !parsed_to.get_is_dummy() {
+ parsed_orchard.add_to(parsed_to);
+ }
- Ok::<_, ZcashError>(())
- })?;
+ checked_actions.push(ShieldedAction {
+ pool,
+ index,
+ spend_value: action.spend().value().map(|value| value.inner()),
+ spend_has_dummy_sk: action.spend().dummy_sk().is_some(),
+ spend_derivation: action
+ .spend()
+ .zip32_derivation()
+ .as_ref()
+ .map(|derivation| {
+ (
+ *derivation.seed_fingerprint(),
+ derivation.derivation_path().clone(),
+ )
+ }),
+ });
+
+ Ok::<_, ZcashError>(())
+ })?;
// Recompute the bundle balance from the values just reviewed.
let calculated_value_balance = bundle
@@ -483,11 +531,12 @@ fn check_action<P: consensus::Parameters>(
params: &P,
seed_fingerprint: &[u8; 32],
account_index: zip32::AccountId,
- ufvk: &UnifiedFullViewingKey,
+ fvk: &FullViewingKey,
+ keys: &WalletKeys,
action: &orchard::pczt::Action,
flags: &orchard::bundle::Flags,
pool: ShieldedPool,
-) -> Result<(), ZcashError> {
+) -> Result<ParsedTo, ZcashError> {
let pool_label = pool.label();
// Check `cv_net` first so we know that the `value` fields for both the spend and the
// output are present and correct.
@@ -495,9 +544,6 @@ fn check_action<P: consensus::Parameters>(
ZcashError::InvalidPczt(format!("invalid cv_net in {pool_label} action: {e:?}"))
})?;
- let fvk = ufvk.orchard().ok_or(ZcashError::InvalidDataError(
- "orchard fvk is not present".to_string(),
- ))?;
check_action_spend(
params,
seed_fingerprint,
@@ -506,8 +552,7 @@ fn check_action<P: consensus::Parameters>(
action.spend(),
pool,
)?;
- check_action_output(params, ufvk, action, flags, pool)?;
- Ok(())
+ check_action_output(params, keys, action, flags, pool)
}
#[cfg(feature = "cypherpunk")]
@@ -558,11 +603,11 @@ fn check_action_spend<P: consensus::Parameters>(
#[cfg(feature = "cypherpunk")]
fn check_action_output<P: consensus::Parameters>(
params: &P,
- ufvk: &UnifiedFullViewingKey,
+ keys: &WalletKeys,
action: &orchard::pczt::Action,
flags: &orchard::bundle::Flags,
pool: ShieldedPool,
-) -> Result<(), ZcashError> {
+) -> Result<ParsedTo, ZcashError> {
let pool_label = pool.label();
action
.output()
@@ -570,15 +615,15 @@ fn check_action_output<P: consensus::Parameters>(
.map_err(|e| ZcashError::InvalidPczt(format!("invalid {pool_label} action cmx: {e:?}")))?;
// Decode and validate the recipient, rejecting non-zero outputs the device cannot review.
- super::parse::parse_orchard_output(params, ufvk, action, pool)?;
- check_restricted_zero_value_output(ufvk, action, flags, pool)?;
+ let parsed_to = super::parse::parse_orchard_output(params, keys, action, pool)?;
+ check_restricted_zero_value_output(keys, action, flags, pool)?;
- Ok(())
+ Ok(parsed_to)
}
#[cfg(feature = "cypherpunk")]
fn check_restricted_zero_value_output(
- ufvk: &UnifiedFullViewingKey,
+ keys: &WalletKeys,
action: &orchard::pczt::Action,
flags: &orchard::bundle::Flags,
pool: ShieldedPool,
@@ -597,7 +642,7 @@ fn check_restricted_zero_value_output(
pool.label()
))
})?;
- if !super::parse::is_wallet_orchard_address(ufvk, &recipient)? {
+ if !super::parse::is_wallet_orchard_address(keys, &recipient)? {
return Err(ZcashError::InvalidPczt(format!(
"funded {} output paired with a zero-value spend does not belong to the selected account",
pool.label()
diff --git a/rust/apps/zcash/src/pczt/mod.rs b/rust/apps/zcash/src/pczt/mod.rs
index ddcbeae..d63b31e 100644
--- a/rust/apps/zcash/src/pczt/mod.rs
+++ b/rust/apps/zcash/src/pczt/mod.rs
@@ -135,12 +135,35 @@ pub(crate) fn matching_seed_supported_orchard_account(
derivation: Option<&zcash_vendor::orchard::pczt::Zip32Derivation>,
coin_type: u32,
pool: ShieldedPool,
+) -> Result<Option<zcash_vendor::zip32::AccountId>, crate::errors::ZcashError> {
+ matching_seed_supported_orchard_account_parts(
+ seed_fingerprint,
+ derivation.map(|derivation| {
+ (
+ derivation.seed_fingerprint(),
+ derivation.derivation_path().as_slice(),
+ )
+ }),
+ coin_type,
+ pool,
+ )
+}
+
+/// [`matching_seed_supported_orchard_account`] over the derivation's raw
+/// (fingerprint, path) parts, for callers holding derivation parts recorded
+/// during the batch check instead of a `Zip32Derivation` borrow.
+#[cfg(feature = "cypherpunk")]
+pub(crate) fn matching_seed_supported_orchard_account_parts(
+ seed_fingerprint: &[u8; 32],
+ derivation: Option<(&[u8; 32], &[zcash_vendor::zip32::ChildIndex])>,
+ coin_type: u32,
+ pool: ShieldedPool,
) -> Result<Option<zcash_vendor::zip32::AccountId>, crate::errors::ZcashError> {
let pool_label = pool.label();
- let Some(derivation) = derivation else {
+ let Some((derivation_seed_fingerprint, derivation_path)) = derivation else {
return Ok(None);
};
- if derivation.seed_fingerprint() != seed_fingerprint {
+ if derivation_seed_fingerprint != seed_fingerprint {
return Ok(None);
}
@@ -150,7 +173,7 @@ pub(crate) fn matching_seed_supported_orchard_account(
))
};
- let [purpose, path_coin_type, account_index] = &derivation.derivation_path()[..] else {
+ let [purpose, path_coin_type, account_index] = derivation_path else {
return Err(unsupported_path());
};
diff --git a/rust/apps/zcash/src/pczt/parse.rs b/rust/apps/zcash/src/pczt/parse.rs
index 1db18ad..4a05977 100644
--- a/rust/apps/zcash/src/pczt/parse.rs
+++ b/rust/apps/zcash/src/pczt/parse.rs
@@ -211,6 +211,7 @@ pub fn parse_pczt_cypherpunk<P: consensus::Parameters>(
pczt: &Pczt,
) -> Result<ParsedPczt, ZcashError> {
super::validate_supported_pczt(pczt)?;
+ let keys = WalletKeys::derive(ufvk)?;
let mut parsed_orchard = None;
let mut parsed_ironwood = None;
let should_process_ironwood = super::pczt_should_process_ironwood(pczt);
@@ -221,7 +222,7 @@ pub fn parse_pczt_cypherpunk<P: consensus::Parameters>(
parsed_orchard = parse_orchard(
params,
seed_fingerprint,
- ufvk,
+ &keys,
bundle,
ShieldedPool::Orchard,
)
@@ -235,7 +236,7 @@ pub fn parse_pczt_cypherpunk<P: consensus::Parameters>(
parsed_ironwood = parse_orchard(
params,
seed_fingerprint,
- ufvk,
+ &keys,
bundle,
ShieldedPool::Ironwood,
)
@@ -596,7 +597,7 @@ fn parse_transparent_output<P: consensus::Parameters>(
fn parse_orchard<P: consensus::Parameters>(
params: &P,
seed_fingerprint: &[u8; 32],
- ufvk: &UnifiedFullViewingKey,
+ keys: &WalletKeys,
orchard: &orchard::pczt::Bundle,
pool: ShieldedPool,
) -> Result<Option<ParsedOrchard>, ZcashError> {
@@ -611,7 +612,7 @@ fn parse_orchard<P: consensus::Parameters>(
parsed_orchard.add_from(parsed_from);
}
}
- let parsed_to = parse_orchard_output(params, ufvk, action, pool)?;
+ let parsed_to = parse_orchard_output(params, keys, action, pool)?;
if !parsed_to.get_is_dummy() {
parsed_orchard.add_to(parsed_to);
}
@@ -649,30 +650,58 @@ pub(crate) fn parse_orchard_spend(
#[cfg(feature = "cypherpunk")]
pub(crate) fn is_wallet_orchard_address(
- ufvk: &UnifiedFullViewingKey,
+ keys: &WalletKeys,
address: &Address,
) -> Result<bool, ZcashError> {
- let fvk = ufvk.orchard().ok_or(ZcashError::InvalidDataError(
- "orchard is not present in ufvk".to_string(),
- ))?;
- let external_ivk = fvk.to_ivk(zcash_vendor::zip32::Scope::External);
- let internal_ivk = fvk.to_ivk(zcash_vendor::zip32::Scope::Internal);
-
- Ok(external_ivk.diversifier_index(address).is_some()
- || internal_ivk.diversifier_index(address).is_some())
+ let (external, internal) = keys.address_scope_flags(address);
+ Ok(external || internal)
}
+/// Wallet viewing material derived once per check/parse entry and reused across
+/// Orchard and Ironwood actions. Deriving the incoming viewing keys requires
+/// Sinsemilla commitments; the derived fields are invariant for a given UFVK.
#[cfg(feature = "cypherpunk")]
-fn is_internal_orchard_address(
- ufvk: &UnifiedFullViewingKey,
- address: &Address,
-) -> Result<bool, ZcashError> {
- let fvk = ufvk.orchard().ok_or(ZcashError::InvalidDataError(
- "orchard is not present in ufvk".to_string(),
- ))?;
- let internal_ivk = fvk.to_ivk(zcash_vendor::zip32::Scope::Internal);
+pub(crate) struct WalletKeys {
+ external_ivk: orchard::keys::IncomingViewingKey,
+ internal_ivk: orchard::keys::IncomingViewingKey,
+ external_ovk: OutgoingViewingKey,
+ internal_ovk: OutgoingViewingKey,
+ transparent_internal_ovk: Option<OutgoingViewingKey>,
+}
- Ok(internal_ivk.diversifier_index(address).is_some())
+#[cfg(feature = "cypherpunk")]
+impl WalletKeys {
+ /// Derives every per-UFVK viewing key needed by the parse/check paths, once.
+ /// Produces the "orchard is not present in ufvk" error for a UFVK without
+ /// an Orchard component (the same error the per-call helpers used to raise).
+ pub(crate) fn derive(ufvk: &UnifiedFullViewingKey) -> Result<Self, ZcashError> {
+ let fvk = ufvk.orchard().ok_or(ZcashError::InvalidDataError(
+ "orchard is not present in ufvk".to_string(),
+ ))?;
+ let external_ivk = fvk.to_ivk(zcash_vendor::zip32::Scope::External);
+ let internal_ivk = fvk.to_ivk(zcash_vendor::zip32::Scope::Internal);
+ let external_ovk = fvk.to_ovk(zcash_vendor::zip32::Scope::External).clone();
+ let internal_ovk = fvk.to_ovk(zcash_vendor::zip32::Scope::Internal).clone();
+ let transparent_internal_ovk = ufvk
+ .transparent()
+ .map(|k| orchard::keys::OutgoingViewingKey::from(k.internal_ovk().as_bytes()));
+ Ok(Self {
+ external_ivk,
+ internal_ivk,
+ external_ovk,
+ internal_ovk,
+ transparent_internal_ovk,
+ })
+ }
+
+ /// Returns whether `address` belongs to the wallet's external and internal
+ /// IVK scopes.
+ fn address_scope_flags(&self, address: &Address) -> (bool, bool) {
+ (
+ self.external_ivk.diversifier_index(address).is_some(),
+ self.internal_ivk.diversifier_index(address).is_some(),
+ )
+ }
}
#[cfg(feature = "cypherpunk")]
@@ -701,21 +730,12 @@ pub(crate) fn validate_orchard_user_address<P: consensus::Parameters>(
#[cfg(feature = "cypherpunk")]
pub(crate) fn parse_orchard_output<P: consensus::Parameters>(
params: &P,
- ufvk: &UnifiedFullViewingKey,
+ keys: &WalletKeys,
action: &orchard::pczt::Action,
pool: ShieldedPool,
) -> Result<ParsedTo, ZcashError> {
let pool_label = pool.label();
let output = action.output();
- let fvk = ufvk.orchard().ok_or(ZcashError::InvalidDataError(
- "orchard is not present in ufvk".to_string(),
- ))?;
-
- let external_ovk = fvk.to_ovk(zcash_vendor::zip32::Scope::External).clone();
- let internal_ovk = fvk.to_ovk(zcash_vendor::zip32::Scope::Internal).clone();
- let transparent_internal_ovk = ufvk
- .transparent()
- .map(|k| orchard::keys::OutgoingViewingKey::from(k.internal_ovk().as_bytes()));
// we should verify the cv_net in checking phrase, the transaction checking should failed if the net value is not correct
// so the value should be trustable
@@ -750,8 +770,8 @@ pub(crate) fn parse_orchard_output<P: consensus::Parameters>(
validate_orchard_user_address(params, user_address, &address)?;
}
- let belongs_to_wallet = is_wallet_orchard_address(ufvk, &address)?;
- let is_internal = is_internal_orchard_address(ufvk, &address)?;
+ let (is_external, is_internal) = keys.address_scope_flags(&address);
+ let belongs_to_wallet = is_external || is_internal;
if is_internal_ovk && !belongs_to_wallet {
return Err(ZcashError::InvalidPczt(alloc::format!(
"{pool_label} output was recoverable with an internal OVK but does not belong to this wallet"
@@ -795,19 +815,22 @@ pub(crate) fn parse_orchard_output<P: consensus::Parameters>(
}
};
- let mut keys = vec![(Some(external_ovk), false), (Some(internal_ovk), true)];
+ let mut trial_ovks = vec![
+ (Some(keys.external_ovk.clone()), false),
+ (Some(keys.internal_ovk.clone()), true),
+ ];
- if let Some(ovk) = transparent_internal_ovk {
- keys.push((Some(ovk), true));
+ if let Some(ovk) = &keys.transparent_internal_ovk {
+ trial_ovks.push((Some(ovk.clone()), true));
}
// Require that we can view all non-zero-valued outputs by falling back on direct
// decryption.
- keys.push((None, false));
+ trial_ovks.push((None, false));
let mut parsed_to = None;
- for key in keys {
+ for key in trial_ovks {
// TODO: Should this be a soft error ("catch" the decryption failure error here
// and store it in `ParsedTo` to inform the user that an output of their
// transaction is unreadable, but still give them the option to sign), or a hard
diff --git a/rust/apps/zcash/src/pczt/sign.rs b/rust/apps/zcash/src/pczt/sign.rs
index 619c646..366d6d1 100644
--- a/rust/apps/zcash/src/pczt/sign.rs
+++ b/rust/apps/zcash/src/pczt/sign.rs
@@ -178,17 +178,32 @@ impl Drop for AskCacheSlot {
}
}
-/// One scrubbed spend authorizing key slot shared by the pool signing passes.
+/// One inline, scrubbed spend authorizing key slot shared by every PCZT and
+/// pool pass in a signing request.
+///
+/// Create one cache per request, pass it to each PCZT signed with the same seed,
+/// never reuse it with another seed, and let it drop before the seed leaves
+/// request scope. A hit reuses the cached key without another ZIP 32 derivation.
+/// Changing account scrubs the old key before replacement; dropping the cache
+/// scrubs the final key.
#[cfg(feature = "cypherpunk")]
-struct SpendAuthCache(RefCell<AskCacheSlot>);
+pub struct SpendAuthCache(RefCell<AskCacheSlot>);
#[cfg(feature = "cypherpunk")]
impl SpendAuthCache {
- fn new() -> Self {
+ /// Creates an empty request-scoped cache.
+ pub fn new() -> Self {
Self(RefCell::new(AskCacheSlot::empty()))
}
}
+#[cfg(feature = "cypherpunk")]
+impl Default for SpendAuthCache {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
/// Lean signer for the cypherpunk path. Drives the shallow `low_level_signer` and
/// derives keys / signs each action in place, instead of materializing a full
/// `RoleSigner` (which reconstructs the whole transaction to compute the sighash and
@@ -200,12 +215,8 @@ struct SeedSigner<'a> {
seed: &'a [u8],
seed_fingerprint: [u8; 32],
pool: ShieldedPool,
- /// Single-slot cache for the spend authorizing key. The key depends only on
- /// (seed, account), not on the action or pool, so consecutive actions for one
- /// account derive once and the Orchard and Ironwood passes can share it. A
- /// change of account replaces and scrubs the old key. Interior mutability is
- /// needed because `PcztSigner` signs through `&self`; the slot lives inline
- /// in the signing call and never touches the heap.
+ /// Borrowed so every PCZT and both pool passes can share one scrubbed
+ /// slot. See [`SpendAuthCache`] for the request-scoping contract.
ask_cache: &'a SpendAuthCache,
/// Number of authorizations produced, so `sign_pczt` can distinguish "nothing of
/// ours to sign" (`PcztNoMyInputs`) from a successful signing.
@@ -364,9 +375,24 @@ pub fn sign_pczt(pczt: Pczt, seed: &[u8]) -> crate::Result<Vec<u8>> {
/// `sign_pczt`, but returns the stamped, redacted PCZT without serializing it,
/// so callers that still need the parsed value (in-memory post-sign
-/// verification) avoid a byte round trip.
+/// verification) avoid a byte round trip. Derives keys into a fresh
+/// [`SpendAuthCache`]; the batch signing path instead reuses a request-scoped
+/// cache so PCZTs for the selected account share one derivation.
#[cfg(feature = "cypherpunk")]
pub fn sign_and_redact_pczt(pczt: Pczt, seed: &[u8]) -> crate::Result<Pczt> {
+ sign_and_redact_pczt_with_cache(pczt, seed, &SpendAuthCache::new())
+}
+
+/// [`sign_and_redact_pczt`] with a caller-provided [`SpendAuthCache`]. The normal
+/// batch path derives its selected account key once and reuses it across PCZTs
+/// and pools. An account change scrubs and replaces the slot. The cache must not
+/// be reused with another seed.
+#[cfg(feature = "cypherpunk")]
+pub(crate) fn sign_and_redact_pczt_with_cache(
+ pczt: Pczt,
+ seed: &[u8],
+ ask_cache: &SpendAuthCache,
+) -> crate::Result<Pczt> {
super::validate_supported_pczt(&pczt)?;
let seed_fingerprint =
@@ -374,33 +400,28 @@ pub fn sign_and_redact_pczt(pczt: Pczt, seed: &[u8]) -> crate::Result<Pczt> {
let process_ironwood = super::pczt_should_process_ironwood(&pczt);
- // Keep one scrubbed key slot and one lean signer for both pool passes.
- let ask_cache = SpendAuthCache::new();
- let mut seed_signer =
- SeedSigner::new(seed, seed_fingerprint, ShieldedPool::Orchard, &ask_cache);
+ // The orchard signer handles both the transparent inputs and the Orchard bundle
+ // (the pool only changes error labels for shielded actions). Ironwood gets its own.
+ let orchard_signer = SeedSigner::new(seed, seed_fingerprint, ShieldedPool::Orchard, ask_cache);
- // The Orchard pass also handles transparent inputs; the pool only changes
- // error labels and ZIP 32 matching for shielded actions. Propagate signer
- // errors directly so strict path validation remains `InvalidPczt`.
+ // Propagate signer errors directly so strict path validation remains
+ // `InvalidPczt`.
let signer = low_level_signer::Signer::new(pczt);
- let signer = pczt_ext::sign_transparent(signer, &seed_signer)?;
- let signer = pczt_ext::sign_orchard(signer, &seed_signer)?;
+ let signer = pczt_ext::sign_transparent(signer, &orchard_signer)?;
+ let signer = pczt_ext::sign_orchard(signer, &orchard_signer)?;
+ let ironwood_signer =
+ SeedSigner::new(seed, seed_fingerprint, ShieldedPool::Ironwood, ask_cache);
let signer = if process_ironwood {
- seed_signer.pool = ShieldedPool::Ironwood;
- pczt_ext::sign_ironwood(signer, &seed_signer)?
+ pczt_ext::sign_ironwood(signer, &ironwood_signer)?
} else {
signer
};
- if seed_signer.signed.get() == 0 {
+ if orchard_signer.signed.get() + ironwood_signer.signed.get() == 0 {
return Err(ZcashError::PcztNoMyInputs);
}
- // The low-level signer does not borrow the cache, so scrub the cached key
- // before finishing and redacting the response.
- drop(seed_signer);
- drop(ask_cache);
Ok(stamp_and_redact(signer.finish()))
}
@@ -602,25 +623,23 @@ mod tests {
let seed = [7u8; 32];
let fingerprint = calculate_seed_fingerprint(&seed).unwrap();
let cache = SpendAuthCache::new();
- let mut signer = SeedSigner::new(&seed, fingerprint, ShieldedPool::Orchard, &cache);
let account = |i: u32| zip32::AccountId::try_from(i).unwrap();
let fresh = |i: u32| {
let osk = orchard::keys::SpendingKey::from_zip32_seed(&seed, 133, account(i)).unwrap();
ask_scalar_bytes(&orchard::keys::SpendAuthorizingKey::from(&osk))
};
- // Empty-slot miss, hit, replace-on-miss, hit-after-replace, and
- // cross-pool reuse all hand out exactly the key a fresh derivation
- // produces. The same signer and slot are reused across pool passes.
+ // Separate signers model PCZT and pool passes sharing one request-scoped
+ // slot. Every lookup must return the requested account's key, including
+ // after replacing the cached account.
for (pool, i) in [
(ShieldedPool::Orchard, 0u32),
- (ShieldedPool::Orchard, 0),
- (ShieldedPool::Ironwood, 1),
+ (ShieldedPool::Ironwood, 0),
(ShieldedPool::Ironwood, 1),
+ (ShieldedPool::Orchard, 1),
(ShieldedPool::Orchard, 0),
- (ShieldedPool::Ironwood, 2),
] {
- signer.pool = pool;
+ let signer = SeedSigner::new(&seed, fingerprint, pool, &cache);
let bytes = signer
.with_spend_authorizing_key(account(i), |ask| Ok(ask_scalar_bytes(ask)))
.unwrap();
@@ -628,7 +647,24 @@ mod tests {
}
// The slot holds exactly the most recently used account's key.
- assert_eq!(cache.0.borrow().account, Some(account(2)));
+ assert_eq!(cache.0.borrow().account, Some(account(0)));
+ }
+
+ #[test]
+ fn test_ask_cache_can_be_reused_across_pczt_calls() {
+ let sample = signable_sample_pczt();
+ let cache = SpendAuthCache::new();
+
+ for _ in 0..2 {
+ sign_and_redact_pczt_with_cache(
+ Pczt::parse(&sample.bytes).unwrap(),
+ &sample.seed,
+ &cache,
+ )
+ .expect("shared-cache PCZT should sign");
+ }
+
+ assert_eq!(cache.0.borrow().account, Some(zip32::AccountId::ZERO));
}
fn signable_sample_pczt() -> crate::pczt::test_support::SamplePczt {
@@ -731,7 +767,7 @@ mod tests {
.expect("Ironwood PCZT signer should initialize")
.shielded_sighash();
// Mirror the wallet's batch redaction: clearing the anchor rebuilds the
- // anchor-elided request the wallet sends for batch children, with the
+ // anchor-elided request the wallet sends for batch PCZTs, with the
// full-anchor PCZT as its own oracle. The v6 Ironwood sighash does not
// commit the anchor, so the elided form must leave the shielded sighash
// unchanged; a client-provided anchor may equally stay on the wire.
diff --git a/rust/rust_c/src/zcash/mod.rs b/rust/rust_c/src/zcash/mod.rs
index c7eabf2..3105909 100644
--- a/rust/rust_c/src/zcash/mod.rs
+++ b/rust/rust_c/src/zcash/mod.rs
@@ -12,6 +12,8 @@ use crate::{extract_array, extract_array_mut};
use crate::{extract_ptr_with_type, make_free_method};
use alloc::{boxed::Box, format, string::String, string::ToString, vec::Vec};
use app_zcash::get_address;
+#[cfg(feature = "cypherpunk")]
+use app_zcash::pczt::{sign::SpendAuthCache, structs::ParsedPczt};
use core::slice;
use cryptoxide::hashing::sha256;
use cty::c_char;
@@ -19,6 +21,8 @@ use keystore::algorithms::{
ed25519::slip10_ed25519::get_private_key_by_seed,
zcash::{calculate_seed_fingerprint, derive_ufvk},
};
+#[cfg(feature = "cypherpunk")]
+use structs::BatchDisplayCache;
use structs::DisplayPczt;
use structs::DisplayZcashBatch;
use structs::ZcashCheckedPczt;
@@ -209,7 +213,7 @@ pub unsafe extern "C" fn parse_zcash_tx_multi_coins(
}
}
-/// Enforces the count, canonical byte, and exact payload uniqueness limits.
+/// Enforces the count, canonical byte total, and duplicate payload limits.
#[cfg(feature = "cypherpunk")]
fn validate_zcash_batch_payloads(payloads: &[Vec<u8>]) -> Result<(), RustCError> {
if payloads.is_empty() {
@@ -389,16 +393,25 @@ pub unsafe extern "C" fn check_zcash_batch_tx_cypherpunk(
Err(e) => return TransactionCheckResult::from(e).c_ptr(),
};
+ // Each check returns normalized bytes, display rows, and an optional compact
+ // migration classification from one trial-decrypt pass. Parse later converts
+ // the cached rows.
let mut checked_pczts = Vec::with_capacity(payloads.len());
+ let mut rows: Vec<ParsedPczt> = Vec::with_capacity(payloads.len());
+ let mut migration_summaries = Vec::with_capacity(payloads.len());
+ // One check context for the whole batch: the UFVK decode and the wallet
+ // Orchard key derivation depend only on the device UFVK, so they run once
+ // here instead of once per PCZT (see BatchCheckContext).
+ let check_ctx = app_zcash::BatchCheckContext::new(&ufvk_text);
for payload in payloads {
- match app_zcash::check_batch_pczt_cypherpunk(
+ match app_zcash::check_batch_pczt_with_display(
&MainNetwork,
&payload,
- &ufvk_text,
+ &check_ctx,
seed_fingerprint,
account_index,
) {
- Ok(normalized) => {
+ Ok((normalized, parsed, migration_summary)) => {
let pczt = match Pczt::parse(&normalized) {
Ok(pczt) => pczt,
Err(e) => {
@@ -409,11 +422,20 @@ pub unsafe extern "C" fn check_zcash_batch_tx_cypherpunk(
}
};
checked_pczts.push(pczt);
+ rows.push(parsed);
+ migration_summaries.push(migration_summary);
}
Err(e) => return TransactionCheckResult::from(e).c_ptr(),
}
}
+ // Compact eligible Orchard-to-Ironwood transfers by content, independent of
+ // their PCZT positions. Ambiguous batches retain every ordinary row.
+ let display_rows = app_zcash::compact_checked_batch_migration_review(
+ rows.into_iter().zip(migration_summaries),
+ );
+ let display = BatchDisplayCache::new(display_rows);
+
// Rebuild the Postcard request around the normalized PCZTs so parse/sign
// consume exactly what was checked, then preserve the outer request id for
// the eventual batch result.
@@ -430,7 +452,7 @@ pub unsafe extern "C" fn check_zcash_batch_tx_cypherpunk(
Ok(bytes) => bytes,
Err(e) => return TransactionCheckResult::from(e).c_ptr(),
};
- *checked_batch = ZcashCheckedPczt::new(normalized_batch).c_ptr();
+ *checked_batch = ZcashCheckedPczt::new_with_display(normalized_batch, display).c_ptr();
TransactionCheckResult::new().c_ptr()
}
@@ -454,48 +476,37 @@ pub unsafe extern "C" fn parse_zcash_batch_tx_cypherpunk(
))
.c_ptr();
}
+ // `ufvk` and `seed_fingerprint` are retained for ABI/signature stability but
+ // unused now that parse converts the display rows the check pass cached
+ // instead of re-decrypting every output.
+ let _ = (ufvk, seed_fingerprint);
let checked = extract_ptr_with_type!(checked_batch, ZcashCheckedPczt);
- let bytes = match checked.checked_bytes() {
- Ok(bytes) => bytes,
- Err(e) => return TransactionParseResult::from(e).c_ptr(),
- };
- let batch = match parse_checked_zcash_batch(bytes) {
- Ok((_, batch)) => batch,
- Err(e) => return TransactionParseResult::from(e).c_ptr(),
- };
- let ufvk_text = unsafe { recover_c_char(ufvk) };
- let seed_fingerprint = extract_array!(seed_fingerprint, u8, 32);
- let seed_fingerprint = seed_fingerprint.try_into().unwrap();
-
- // Serialize the normalized PCZTs once, then parse each into a complete
- // display model. Eligible Orchard-to-Ironwood transfers are folded by
- // content; ambiguous batches keep their ordinary review pages.
- let payloads = match batch
- .pczts()
- .iter()
- .map(serialize_batch_pczt)
- .collect::<Result<Vec<_>, _>>()
- {
- Ok(payloads) => payloads,
- Err(e) => return TransactionParseResult::from(e).c_ptr(),
- };
- let parsed_items = match app_zcash::parse_batch_with_migration_summary_cypherpunk(
- &MainNetwork,
- payloads.iter().map(Vec::as_slice),
- &ufvk_text,
- seed_fingerprint,
- ) {
- Ok(items) => items,
- Err(e) => return TransactionParseResult::from(e).c_ptr(),
- };
- // Convert only after every PCZT has parsed. These FFI values own heap
- // allocations freed by `free_TransactionParseResult_DisplayZcashBatch`, not
- // Rust `Drop`; an early return after partial conversion would leak memory.
- let display_items: Vec<DisplayPczt> = parsed_items.iter().map(DisplayPczt::from).collect();
+ if let Err(e) = checked.checked_bytes() {
+ return TransactionParseResult::from(e).c_ptr();
+ }
+ if checked.display.is_null() {
+ // Can't happen for a batch checked container (the batch check always
+ // stores a cache), but fail closed rather than silently re-deriving.
+ return TransactionParseResult::from(RustCError::InvalidData(
+ "no checked Zcash batch display available".to_string(),
+ ))
+ .c_ptr();
+ }
+ // Convert the cached rows into fresh owned FFI structs. There is no fallible
+ // step after the first `DisplayPczt` is built, so the "materialize only after
+ // all fallible steps" leak-safety is trivially preserved.
+ let display_items = batch_display_items(&*checked.display);
TransactionParseResult::success(DisplayZcashBatch::from(display_items).c_ptr()).c_ptr()
}
+/// Converts the cached review rows into fresh FFI display structs. The cache
+/// already contains either the compacted review or all ordinary PCZT rows.
+#[cfg(feature = "cypherpunk")]
+fn batch_display_items(cache: &BatchDisplayCache) -> Vec<DisplayPczt> {
+ cache.rows().iter().map(DisplayPczt::from).collect()
+}
+
#[cfg(feature = "cypherpunk")]
unsafe fn sign_zcash_batch_tx_cypherpunk_dynamic(
checked_batch: Ptr<ZcashCheckedPczt>,
@@ -535,6 +546,9 @@ unsafe fn sign_zcash_batch_tx_cypherpunk_dynamic(
let mut results = Vec::new();
let mut error = None;
+ // One scrubbed spend-auth slot for the whole request. The
+ // selected account key stays cached across every batch PCZT.
+ let ask_cache = SpendAuthCache::new();
// Preserve request order and emit nothing unless every PCZT signs.
for pczt in batch.pczts() {
let payload = match serialize_batch_pczt(pczt) {
@@ -544,12 +558,13 @@ unsafe fn sign_zcash_batch_tx_cypherpunk_dynamic(
break;
}
};
- match app_zcash::sign_checked_batch_pczt(
+ match app_zcash::sign_checked_batch_pczt_with_cache(
&MainNetwork,
&payload,
seed,
&seed_fingerprint,
account_index,
+ &ask_cache,
) {
Ok(payload) => {
match app_zcash::extract_compact_sigs_from_signed_pczt(&payload) {
@@ -568,6 +583,9 @@ unsafe fn sign_zcash_batch_tx_cypherpunk_dynamic(
}
}
}
+ // End the secret's lifetime before response serialization
+ // and UR encoding, while retaining it across every PCZT.
+ drop(ask_cache);
if let Some(error) = error {
error
@@ -1159,4 +1177,72 @@ mod tests {
assert_eq!(String::from_utf8(pt).unwrap(), "hello world");
}
+
+ /// A minimal display row; the real content parity is covered by the app-level
+ /// tests, so this only exercises the rust_c cache plumbing.
+ #[cfg(feature = "cypherpunk")]
+ fn sample_parsed_pczt() -> ParsedPczt {
+ ParsedPczt::new(
+ None,
+ None,
+ None,
+ "1 ZEC".to_string(),
+ "0.0001 ZEC".to_string(),
+ false,
+ )
+ }
+
+ /// The batch parse FFI reads the display cache the check stored and returns one
+ /// display per cached row without re-deriving anything. The item count is
+ /// asserted through the conversion helper (`TransactionParseResult::data` is
+ /// private), then the full FFI is driven end-to-end for the no-crash path.
+ #[cfg(feature = "cypherpunk")]
+ #[test]
+ fn test_parse_zcash_batch_reads_display_cache() {
+ let items = batch_display_items(&BatchDisplayCache::new(vec![
+ sample_parsed_pczt(),
+ sample_parsed_pczt(),
+ sample_parsed_pczt(),
+ ]));
+ assert_eq!(
+ items.len(),
+ 3,
+ "the cache must yield one display per review row"
+ );
+ for item in &items {
+ unsafe { item.free() };
+ }
+
+ let cache = BatchDisplayCache::new(vec![sample_parsed_pczt(), sample_parsed_pczt()]);
+ let checked_ptr =
+ ZcashCheckedPczt::new_with_display(b"normalized-batch-bytes".to_vec(), cache).c_ptr();
+ let result = unsafe {
+ parse_zcash_batch_tx_cypherpunk(
+ checked_ptr,
+ core::ptr::null_mut(),
+ core::ptr::null_mut(),
+ false,
+ )
+ };
+ assert!(
+ !result.is_null(),
+ "parse must produce a result for a cache-bearing container"
+ );
+ unsafe {
+ Box::from_raw(result).free();
+ free_zcash_checked_pczt(checked_ptr);
+ }
+ }
+
+ /// Freeing a checked container that carries a display cache must free both the
+ /// bytes and the cache exactly once (reaching the end without an allocator
+ /// abort is the assertion).
+ #[cfg(feature = "cypherpunk")]
+ #[test]
+ fn test_free_cache_bearing_container_is_clean() {
+ let cache = BatchDisplayCache::new(vec![sample_parsed_pczt(), sample_parsed_pczt()]);
+ let checked_ptr =
+ ZcashCheckedPczt::new_with_display(b"normalized-batch-bytes".to_vec(), cache).c_ptr();
+ unsafe { free_zcash_checked_pczt(checked_ptr) };
+ }
}
diff --git a/rust/rust_c/src/zcash/structs.rs b/rust/rust_c/src/zcash/structs.rs
index f3c377c..cd133e6 100644
--- a/rust/rust_c/src/zcash/structs.rs
+++ b/rust/rust_c/src/zcash/structs.rs
@@ -216,25 +216,71 @@ impl_c_ptrs!(
DisplayOrchard
);
+/// The batch display rows produced by the check pass and converted to FFI
+/// structs by the batch parse FFI, so parse no longer re-decrypts every output.
+///
+/// Opaque to C: this is a plain Rust struct (deliberately not `#[repr(C)]`), and C
+/// only ever holds it behind the [`ZcashCheckedPczt::display`] pointer without
+/// dereferencing it. The batch check FFI builds it; the batch parse FFI reads it
+/// Rust-side and turns each [`ParsedPczt`] into a [`DisplayPczt`].
+#[cfg(feature = "cypherpunk")]
+pub struct BatchDisplayCache {
+ rows: Vec<ParsedPczt>,
+}
+
+#[cfg(feature = "cypherpunk")]
+impl BatchDisplayCache {
+ /// Stores the final review rows after any migration compaction.
+ pub fn new(rows: Vec<ParsedPczt>) -> Self {
+ Self { rows }
+ }
+
+ /// Returns the final review rows that batch parse converts for the C UI.
+ pub fn rows(&self) -> &[ParsedPczt] {
+ &self.rows
+ }
+}
+
/// Normalized transaction bytes verified during check and retained by C between
/// the check, display, and sign stages (`checked_PCZT` on the C side).
///
/// `data` is opaque to C: the normalized PCZT encoding in the single-transaction
-/// flow, or the normalized `ZcashSignBatch` CBOR in the batch flow. Construct
-/// exclusively from check results.
+/// flow, or the normalized `ZcashSignBatch` CBOR in the batch flow. `display`
+/// (cypherpunk only) is the opaque [`BatchDisplayCache`] the batch check builds
+/// so parse converts stored rows instead of re-decrypting; it is null for the
+/// single-transaction and multi-coins flows. Construct exclusively from check
+/// results.
#[repr(C)]
pub struct ZcashCheckedPczt {
pub data: Ptr<VecFFI<u8>>,
+ /// Opaque batch display cache (null for the single-tx / multi-coins flows).
+ /// C never dereferences this.
+ #[cfg(feature = "cypherpunk")]
+ pub display: Ptr<BatchDisplayCache>,
}
impl ZcashCheckedPczt {
- /// Wraps bytes verified during check.
+ /// Wraps bytes verified during check. The display cache is null; the batch
+ /// flow uses [`Self::new_with_display`] instead.
pub fn new(data: Vec<u8>) -> Self {
Self {
data: VecFFI::from(data).c_ptr(),
+ #[cfg(feature = "cypherpunk")]
+ display: null_mut(),
}
}
+ /// Wraps checked batch bytes together with the display cache the check
+ /// produced, so the batch parse FFI converts the stored rows instead of
+ /// re-decrypting every output. The cache is freed with the container in
+ /// [`Free::free`].
+ #[cfg(feature = "cypherpunk")]
+ pub fn new_with_display(data: Vec<u8>, display: BatchDisplayCache) -> Self {
+ let mut checked = Self::new(data);
+ checked.display = alloc::boxed::Box::into_raw(alloc::boxed::Box::new(display));
+ checked
+ }
+
/// Borrows the bytes produced by a successful check.
pub unsafe fn checked_bytes(&self) -> Result<&[u8], RustCError> {
if self.data.is_null() {
@@ -255,6 +301,12 @@ impl Free for ZcashCheckedPczt {
let vec_ffi = alloc::boxed::Box::from_raw(self.data);
drop(Vec::from_raw_parts(vec_ffi.data, vec_ffi.size, vec_ffi.cap));
}
+ // Free the batch display cache exactly once when present (the single-tx
+ // and multi-coins flows leave it null).
+ #[cfg(feature = "cypherpunk")]
+ if !self.display.is_null() {
+ drop(alloc::boxed::Box::from_raw(self.display));
+ }
}
}
diff --git a/src/ui/gui_components/gui_pending_hintbox.c b/src/ui/gui_components/gui_pending_hintbox.c
index 59ea4a0..80e4f66 100644
--- a/src/ui/gui_components/gui_pending_hintbox.c
+++ b/src/ui/gui_components/gui_pending_hintbox.c
@@ -6,9 +6,11 @@ static lv_obj_t *g_pendingHintBox = NULL;
static bool g_hasSubtitle = false;
static lv_obj_t *g_subTitleLabel = NULL;
-void GuiNoPendingHintBoxOpen(const char *title)
+// Builds the static loading sheet with an optional explanatory line.
+static void GuiNoPendingHintBoxOpenInternal(const char *title, const char *subtitle)
{
- uint16_t h = 140;
+ bool hasSubtitle = subtitle != NULL;
+ uint16_t h = hasSubtitle ? 188 : 140;
uint16_t w = 480;
lv_obj_t *bgCont = GuiCreateContainer(w, 800);
lv_obj_set_style_bg_opa(bgCont, 0, 0);
@@ -36,11 +38,28 @@ void GuiNoPendingHintBoxOpen(const char *title)
lv_obj_set_style_border_width(downCont, 0, LV_PART_MAIN | LV_STATE_DEFAULT);
lv_obj_t *label = GuiCreateTextLabel(bgCont, title);
- lv_obj_align(label, LV_ALIGN_BOTTOM_MID, 0, -50);
+ lv_obj_align(label, LV_ALIGN_BOTTOM_MID, 0, hasSubtitle ? -96 : -50);
+
+ if (hasSubtitle) {
+ label = GuiCreateNoticeLabel(bgCont, subtitle);
+ lv_obj_set_width(label, 408);
+ lv_obj_set_style_text_align(label, LV_TEXT_ALIGN_CENTER, 0);
+ lv_obj_align(label, LV_ALIGN_BOTTOM_MID, 0, -48);
+ }
g_pendingHintBox = bgCont;
}
+void GuiNoPendingHintBoxOpen(const char *title)
+{
+ GuiNoPendingHintBoxOpenInternal(title, NULL);
+}
+
+void GuiNoPendingHintBoxOpenWithSubtitle(const char *title, const char *subtitle)
+{
+ GuiNoPendingHintBoxOpenInternal(title, subtitle);
+}
+
void GuiPendingHintBoxOpen(const char *title, const char *subtitle)
{
g_hasSubtitle = subtitle != NULL;
diff --git a/src/ui/gui_components/gui_pending_hintbox.h b/src/ui/gui_components/gui_pending_hintbox.h
index 3398884..5e18f12 100644
--- a/src/ui/gui_components/gui_pending_hintbox.h
+++ b/src/ui/gui_components/gui_pending_hintbox.h
@@ -4,7 +4,8 @@
void GuiPendingHintBoxRemove();
void GuiPendingHintBoxOpen(const char *title, const char *subtitle);
void GuiNoPendingHintBoxOpen(const char *title);
+void GuiNoPendingHintBoxOpenWithSubtitle(const char *title, const char *subtitle);
void GuiUpdatePendingHintBoxSubtitle(const char *subtitle);
void GuiPendingHintBoxMoveToTargetParent(lv_obj_t *parent);
-#endif
\ No newline at end of file
+#endif
diff --git a/src/ui/gui_views/multi/cypherpunk/gui_zcash_batch_view.c b/src/ui/gui_views/multi/cypherpunk/gui_zcash_batch_view.c
index da21aff..14f6569 100644
--- a/src/ui/gui_views/multi/cypherpunk/gui_zcash_batch_view.c
+++ b/src/ui/gui_views/multi/cypherpunk/gui_zcash_batch_view.c
@@ -64,7 +64,7 @@ int32_t GuiZcashBatchViewEventProcess(void *self, uint16_t usEvent, void *param,
GuiPendingHintBoxRemove();
break;
case SIG_SHOW_TRANSACTION_LOADING:
- GuiNoPendingHintBoxOpen(_("Loading"));
+ GuiNoPendingHintBoxOpenWithSubtitle(_("Loading"), _("zcash_batch_loading_hint"));
break;
default:
return ERR_GUI_UNHANDLED;
diff --git a/src/ui/lv_i18n/data.csv b/src/ui/lv_i18n/data.csv
index 603afde..98ff52a 100644
--- a/src/ui/lv_i18n/data.csv
+++ b/src/ui/lv_i18n/data.csv
@@ -896,6 +896,7 @@ Wallet Profile,24,wallet_profile_mid_btn,Wallet Profile,Профиль коше
,20,connect_unisat_link,https://keyst.one/t/3rd/unisat,https://keyst.one/t/3rd/unisat,https://keyst.one/t/3rd/unisat,https://keyst.one/t/3rd/unisat,https://keyst.one/t/3rd/unisat,https://keyst.one/t/3rd/unisat,https://keyst.one/t/3rd/unisat
,20,connect_unisat_title,UniSat,UniSat,UniSat,UniSat,UniSat,UniSat,UniSat
,24,Loading,Loading...,Загрузка...,로딩 중...,加载中...,Cargando...,Laden...,読み込み中...
+,20,zcash_batch_loading_hint,This may take a few minutes.,Это может занять несколько минут.,몇 분 정도 걸릴 수 있습니다.,这可能需要数分钟。,Esto puede tardar unos minutos.,Dies kann einige Minuten dauern.,数分かかる場合があります。
,20,catalyst_transactions_notice,Ensure the address matches. Please verify carefully.,"Убедитесь, что адрес совпадает. Тщательно проверьте.",주소가 일치하는지 확인하세요. 주의깊게 검증해 주세요.,请确保地址匹配。仔细核对。,Asegúrese de que la dirección coincida. Verifique cuidadosamente.,Prüfen Sie die Adresse sorgfältig.,アドレスが一致することを確認してください。慎重に確認してください
,20,unknown_erc20_warning,"Unknown ERC20 Token detected, please carefully verify the transaction.","Обнаружен неизвестный токен ERC20, пожалуйста, тщательно проверьте транзакцию.",알려지지 않은 ERC20 토큰이 감지되었습니다. 거래를 주의 깊게 확인해 주세요.,"未收录的ERC20代币,请谨慎验证交易信息","Se ha detectado un token ERC20 desconocido, por favor verifique cuidadosamente la transacción","Unbekanntes ERC20-Token erkannt, bitte überprüfen Sie die Transaktion sorgfältig","未知のERC20トークンが検出されました,取引を慎重に確認してください"
,24,ton_mnemonic_generating_title,Creating... Please wait,Создание... Подождите,생성 중입니다... 기다려주십시오,创建中,请稍候,"Creando... Por favor, espere",Erzeugen... Bitte warten,作成中... お待ちください
diff --git a/src/ui/lv_i18n/lv_i18n.c b/src/ui/lv_i18n/lv_i18n.c
index 08a8d59..8dbae97 100644
--- a/src/ui/lv_i18n/lv_i18n.c
+++ b/src/ui/lv_i18n/lv_i18n.c
@@ -968,6 +968,7 @@ const static lv_i18n_phrase_t en_singulars[] = {
{"xmr_txo_total_amount_desc", "This amount represents the total balance of the TXOs included in this QR code for signing. It may not reflect the full balance in your software wallet or the exact transaction amount."},
{"xmr_txo_total_amount_link", "https://keyst.one/t/3rd/cake"},
{"xmr_txo_total_amount_title", "TXO Total Amount"},
+ {"zcash_batch_loading_hint", "This may take a few minutes."},
{NULL, NULL} // End mark
};
@@ -1922,6 +1923,7 @@ const static lv_i18n_phrase_t de_singulars[] = {
{"xmr_txo_total_amount_desc", "Dieser Betrag repräsentiert das Gesamtsaldo der TXOs, die in diesem QR-Code zum Signieren enthalten sind. Er spiegelt möglicherweise nicht den vollständigen Saldo in Ihrer Software-Wallet oder den genauen Transaktionsbetrag wider."},
{"xmr_txo_total_amount_link", "https://keyst.one/t/3rd/cake"},
{"xmr_txo_total_amount_title", "Gesamtbetrag der TXO"},
+ {"zcash_batch_loading_hint", "Dies kann einige Minuten dauern."},
{NULL, NULL} // End mark
};
@@ -2876,6 +2878,7 @@ const static lv_i18n_phrase_t es_singulars[] = {
{"xmr_txo_total_amount_desc", "Esta cantidad representa el saldo total de los TXO incluidos en este código QR para firmar. Puede no reflejar el saldo completo en su cartera de software o el monto exacto de la transacción."},
{"xmr_txo_total_amount_link", "https://keyst.one/t/3rd/cake"},
{"xmr_txo_total_amount_title", "Cantidad total de TXO"},
+ {"zcash_batch_loading_hint", "Esto puede tardar unos minutos."},
{NULL, NULL} // End mark
};
@@ -3827,6 +3830,7 @@ const static lv_i18n_phrase_t ja_singulars[] = {
{"xmr_txo_total_amount_desc", "この金額は、署名用のこのQRコードに含まれるTXOの合計残高を表しています。これは、ソフトウェアウォレットの全残高や正確な取引額を反映していない可能性があります。"},
{"xmr_txo_total_amount_link", "https://keyst.one/t/3rd/cake"},
{"xmr_txo_total_amount_title", "TXO 合計額"},
+ {"zcash_batch_loading_hint", "数分かかる場合があります。"},
{NULL, NULL} // End mark
};
@@ -4776,6 +4780,7 @@ const static lv_i18n_phrase_t ko_singulars[] = {
{"xmr_txo_total_amount_desc", "이 금액은 서명을 위해 이 QR 코드에 포함된 TXO의 총 잔액을 나타냅니다. 이는 소프트웨어 지갑의 전체 잔액이나 정확한 거래 금액을 반영하지 않을 수 있습니다."},
{"xmr_txo_total_amount_link", "https://keyst.one/t/3rd/cake"},
{"xmr_txo_total_amount_title", "TXO 총액"},
+ {"zcash_batch_loading_hint", "몇 분 정도 걸릴 수 있습니다."},
{NULL, NULL} // End mark
};
@@ -5729,6 +5734,7 @@ const static lv_i18n_phrase_t pl_singulars[] = {
{"xmr_txo_total_amount_desc", "Kwota ta reprezentuje całkowite saldo TXO ujęte w tym kodzie QR do podpisania. Może nie odzwierciedlać pełnego salda w portfelu oprogramowania lub dokładnej kwoty transakcji."},
{"xmr_txo_total_amount_link", "https://keyst.one/t/3rd/cake"},
{"xmr_txo_total_amount_title", "Łączna kwota TXO"},
+ {"zcash_batch_loading_hint", "Może to potrwać kilka minut."},
{NULL, NULL} // End mark
};
@@ -6686,6 +6692,7 @@ const static lv_i18n_phrase_t ru_singulars[] = {
{"xmr_txo_total_amount_desc", "Эта сумма представляет собой общий баланс TXO, включенных в этот QR-код для подписи. Она может не отражать полный баланс вашего программного кошелька или точную сумму транзакции."},
{"xmr_txo_total_amount_link", "https://keyst.one/xmr/account"},
{"xmr_txo_total_amount_title", "Общая сумма TXO"},
+ {"zcash_batch_loading_hint", "Это может занять несколько минут."},
{NULL, NULL} // End mark
};
@@ -7643,6 +7650,7 @@ const static lv_i18n_phrase_t zh_cn_singulars[] = {
{"xmr_txo_total_amount_desc", "此金额代表此二维码中包含用于签署的TXO的总余额。它可能不反映您的软件钱包中的全额余额或交易的确切金额。"},
{"xmr_txo_total_amount_link", "https://keyst.one/t/3rd/cake"},
{"xmr_txo_total_amount_title", "TXO 总金额"},
+ {"zcash_batch_loading_hint", "这可能需要数分钟。"},
{NULL, NULL} // End mark
};
Why this scored 19/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.