feat(zcash): aggregate migration review
What changed, and why it matters
This commit adds a new compact review screen for batches of Zcash 'migration' transactions (moving funds from the Orchard pool to the newer Ironwood pool). It also fixes a small user-experience issue: the device will no longer auto-lock while a long batch of transactions is loading or being signed. The change is a feature addition with defensive checks, not a fix for an active security bug.
Treat as a normal feature commit. Reviewers should verify that the shape checks in `require_migration_display_shape` and `summarize_migration_actions` cannot be bypassed, that checked arithmetic covers all aggregation paths, and that the new FFI path does not leak `DisplayPczt` allocations on error (the commit explicitly defers display struct construction until after fallible steps).
Security signals we found
New transaction-parsing path for batch Zcash PCZTs with shape validation and checked arithmetic
Fallback to full per-message review when compact summary cannot safely represent a child
Wallet-ownership check on migration Ironwood outputs
Rejection of transparent inputs/outputs in migration summary to prevent hidden fee understatement
Auto-lock timeout reset during long batch loading/signing to avoid UI lockout
Evidence from the diff
The patch introduces parse_batch_with_migration_summary_cypherpunk and helpers in rust/apps/zcash/src/lib.rs that, when a Zcash batch contains more than one message, attempt to fold later Orchard→Ironwood migration children into a single summarized ParsedPczt. If the compact summary cannot represent a child (e.g., because it has transparent/Sapling components, a memo, a non-wallet-owned output, or an undecryptable output), the code falls back to the ordinary per-message review. The summary enforces shape constraints, uses checked arithmetic, and validates that the Ironwood output is wallet-owned. UI changes in gui_zcash.c, gui_model.c, and gui_transaction_signature_widgets.c call ClearLockScreenTime() before re-enabling auto-lock so long parsing/signing operations do not lock the user out.
Changed components
rust/apps/zcash/src/lib.rsrust/apps/zcash/src/pczt/mod.rsrust/apps/zcash/src/pczt/parse.rsrust/rust_c/src/zcash/mod.rssrc/ui/gui_chain/multi/gui_zcash.csrc/ui/gui_model/gui_model.csrc/ui/gui_widgets/gui_transaction_signature_widgets.cInspect captured patch +986 / −11
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c4e9ecc..251de9e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
### What's new
1. Added support for Zcash batch PCZT signing
+2. The device no longer auto-locks while a long Zcash batch review is loading
### Bug Fixes
diff --git a/rust/apps/zcash/src/lib.rs b/rust/apps/zcash/src/lib.rs
index fea3fd4..79c7e1f 100644
--- a/rust/apps/zcash/src/lib.rs
+++ b/rust/apps/zcash/src/lib.rs
@@ -11,7 +11,13 @@ use alloc::{
string::{String, ToString},
vec::Vec,
};
+// The aggregate migration review is the only consumer of these; keep them off
+// the non-cypherpunk build so it stays warning-free.
+#[cfg(feature = "cypherpunk")]
+use alloc::{format, vec};
use pczt::structs::ParsedPczt;
+#[cfg(feature = "cypherpunk")]
+use pczt::structs::{ParsedFrom, ParsedOrchard, ParsedTo};
use zcash_vendor::{
zcash_keys::keys::{UnifiedAddressRequest, UnifiedFullViewingKey},
zcash_protocol::consensus::{self},
@@ -270,7 +276,7 @@ pub fn check_and_parse_batch_pczt_cypherpunk<P: consensus::Parameters>(
) -> Result<ParsedPczt> {
let mut pczt = pczt::parse_pczt(pczt_bytes)?;
// Resolve compact field representations up front so the single-pass check
- // sees complete actions, matching `preflight_batch_pczt_cypherpunk`.
+ // sees complete actions, matching the standalone batch check.
pczt.resolve_fields().map_err(|e| {
ZcashError::InvalidPczt(alloc::format!("resolve compact PCZT fields: {e:?}"))
})?;
@@ -324,6 +330,389 @@ pub fn check_and_parse_batch_pczt_cypherpunk<P: consensus::Parameters>(
}
}
+/// Values for one checked migration child, in zatoshis.
+#[cfg(feature = "cypherpunk")]
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+struct BatchMigrationChildSummary {
+ input: u64,
+ output: u64,
+ fee: u64,
+}
+
+/// Totals and per-child rows for the compact migration review.
+#[cfg(feature = "cypherpunk")]
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+struct BatchMigrationSummary {
+ migrations: u32,
+ total_input: u64,
+ total_output: u64,
+ total_fee: u64,
+ children: Vec<BatchMigrationChildSummary>,
+}
+
+#[cfg(feature = "cypherpunk")]
+impl BatchMigrationSummary {
+ /// Adds a checked child summary using checked arithmetic.
+ fn add_child(&mut self, child: &BatchMigrationSummary) -> Result<()> {
+ self.migrations = self
+ .migrations
+ .checked_add(child.migrations)
+ .ok_or_else(|| ZcashError::InvalidPczt("migration count overflow".to_string()))?;
+ self.total_input = self
+ .total_input
+ .checked_add(child.total_input)
+ .ok_or_else(|| ZcashError::InvalidPczt("migration input overflow".to_string()))?;
+ self.total_output = self
+ .total_output
+ .checked_add(child.total_output)
+ .ok_or_else(|| ZcashError::InvalidPczt("migration output overflow".to_string()))?;
+ self.total_fee = self
+ .total_fee
+ .checked_add(child.total_fee)
+ .ok_or_else(|| ZcashError::InvalidPczt("migration fee overflow".to_string()))?;
+ if child.children.is_empty() {
+ self.children.push(BatchMigrationChildSummary {
+ input: child.total_input,
+ output: child.total_output,
+ fee: child.total_fee,
+ });
+ } else {
+ self.children.extend(child.children.iter().copied());
+ }
+ Ok(())
+ }
+
+ /// Builds the display model for the compact migration review.
+ fn to_parsed_pczt(&self) -> ParsedPczt {
+ let children = if self.children.is_empty() {
+ vec![BatchMigrationChildSummary {
+ input: self.total_input,
+ output: self.total_output,
+ fee: self.total_fee,
+ }]
+ } else {
+ self.children.clone()
+ };
+
+ let orchard = ParsedOrchard::new(
+ children
+ .iter()
+ .enumerate()
+ .map(|(index, child)| {
+ ParsedFrom::new(
+ Some(format!(
+ "Migration #{} Orchard note from selected account",
+ index + 1
+ )),
+ pczt::parse::format_zec_value(child.input as f64),
+ child.input,
+ true,
+ )
+ })
+ .collect(),
+ Vec::new(),
+ );
+ let ironwood = ParsedOrchard::new(
+ Vec::new(),
+ children
+ .iter()
+ .enumerate()
+ .map(|(index, child)| {
+ ParsedTo::new(
+ format!("Migration #{} wallet Ironwood output", index + 1),
+ pczt::parse::format_zec_value(child.output as f64),
+ child.output,
+ true,
+ false,
+ None,
+ )
+ })
+ .collect(),
+ );
+
+ ParsedPczt::new(
+ None,
+ Some(orchard),
+ Some(ironwood),
+ pczt::parse::format_zec_value(self.total_output as f64),
+ pczt::parse::format_zec_value(self.total_fee as f64),
+ false,
+ )
+ }
+}
+
+/// Requires an action value to be present, returning it (zero is a valid
+/// value; callers classify zero themselves).
+#[cfg(feature = "cypherpunk")]
+fn require_action_value(value: Option<u64>, label: &str) -> Result<u64> {
+ value.ok_or_else(|| ZcashError::InvalidPczt(format!("missing {label} value")))
+}
+
+/// Validates the funded migration shape and computes its totals.
+#[cfg(feature = "cypherpunk")]
+fn summarize_migration_actions(
+ ufvk: &UnifiedFullViewingKey,
+ pczt: &Pczt,
+) -> Result<BatchMigrationSummary> {
+ use zcash_vendor::pczt::roles::verifier::{OrchardError, Verifier};
+
+ // Reject transparent components at the wire level so their values cannot be
+ // omitted from the fee.
+ if !pczt.transparent().inputs().is_empty() {
+ return Err(ZcashError::InvalidPczt(
+ "migration summary does not support transparent inputs".to_string(),
+ ));
+ }
+ if !pczt.transparent().outputs().is_empty() {
+ return Err(ZcashError::InvalidPczt(
+ "migration summary does not support transparent outputs".to_string(),
+ ));
+ }
+
+ let mut orchard_spends = 0u32;
+ let mut orchard_outputs = 0u32;
+ let mut ironwood_spends = 0u32;
+ let mut ironwood_outputs = 0u32;
+ let mut total_input = 0u64;
+ let mut total_output = 0u64;
+
+ let map_verifier_error = |error: OrchardError<ZcashError>| match error {
+ OrchardError::Custom(error) => error,
+ error => ZcashError::InvalidDataError(format!("{error:?}")),
+ };
+
+ // Values are read through the Verifier's parsed view; the wire structs of
+ // the pinned pczt revision expose no spend-value getter.
+ let verifier = Verifier::new(pczt.clone())
+ .with_orchard(|bundle| {
+ for action in bundle.actions().iter() {
+ let spend_value = require_action_value(
+ action.spend().value().map(|v| v.inner()),
+ "Orchard spend",
+ )
+ .map_err(OrchardError::Custom)?;
+ if spend_value != 0 {
+ orchard_spends = orchard_spends.checked_add(1).ok_or_else(|| {
+ OrchardError::Custom(ZcashError::InvalidPczt(
+ "Orchard spend count overflow".to_string(),
+ ))
+ })?;
+ total_input = total_input.checked_add(spend_value).ok_or_else(|| {
+ OrchardError::Custom(ZcashError::InvalidPczt(
+ "migration input overflow".to_string(),
+ ))
+ })?;
+ }
+
+ let output_value = require_action_value(
+ action.output().value().map(|v| v.inner()),
+ "Orchard output",
+ )
+ .map_err(OrchardError::Custom)?;
+ if output_value != 0 {
+ orchard_outputs = orchard_outputs.checked_add(1).ok_or_else(|| {
+ OrchardError::Custom(ZcashError::InvalidPczt(
+ "Orchard output count overflow".to_string(),
+ ))
+ })?;
+ }
+ }
+ Ok(())
+ })
+ .map_err(map_verifier_error)?;
+
+ verifier
+ .with_ironwood(|bundle| {
+ for action in bundle.actions().iter() {
+ let spend_value = require_action_value(
+ action.spend().value().map(|v| v.inner()),
+ "Ironwood spend",
+ )
+ .map_err(OrchardError::Custom)?;
+ if spend_value != 0 {
+ ironwood_spends = ironwood_spends.checked_add(1).ok_or_else(|| {
+ OrchardError::Custom(ZcashError::InvalidPczt(
+ "Ironwood spend count overflow".to_string(),
+ ))
+ })?;
+ }
+
+ let output_value = require_action_value(
+ action.output().value().map(|v| v.inner()),
+ "Ironwood output",
+ )
+ .map_err(OrchardError::Custom)?;
+ if output_value == 0 {
+ continue;
+ }
+
+ let recipient = action.output().recipient().ok_or_else(|| {
+ OrchardError::Custom(ZcashError::InvalidPczt(
+ "missing Ironwood output recipient".to_string(),
+ ))
+ })?;
+ if !pczt::parse::is_wallet_orchard_address(ufvk, &recipient)
+ .map_err(OrchardError::Custom)?
+ {
+ return Err(OrchardError::Custom(ZcashError::InvalidPczt(
+ "migration Ironwood output is not wallet-owned".to_string(),
+ )));
+ }
+
+ ironwood_outputs = ironwood_outputs.checked_add(1).ok_or_else(|| {
+ OrchardError::Custom(ZcashError::InvalidPczt(
+ "Ironwood output count overflow".to_string(),
+ ))
+ })?;
+ total_output = total_output.checked_add(output_value).ok_or_else(|| {
+ OrchardError::Custom(ZcashError::InvalidPczt(
+ "migration output overflow".to_string(),
+ ))
+ })?;
+ }
+ Ok(())
+ })
+ .map_err(map_verifier_error)?;
+
+ if orchard_spends != 1 || orchard_outputs != 0 || ironwood_spends != 0 || ironwood_outputs != 1
+ {
+ return Err(ZcashError::InvalidPczt(format!(
+ "unsupported migration summary shape orchard_spends={orchard_spends} orchard_outputs={orchard_outputs} ironwood_spends={ironwood_spends} ironwood_outputs={ironwood_outputs}"
+ )));
+ }
+
+ let total_fee = total_input
+ .checked_sub(total_output)
+ .ok_or_else(|| ZcashError::InvalidPczt("migration output exceeds input".to_string()))?;
+
+ Ok(BatchMigrationSummary {
+ migrations: 1,
+ total_input,
+ total_output,
+ total_fee,
+ children: vec![BatchMigrationChildSummary {
+ input: total_input,
+ output: total_output,
+ fee: total_fee,
+ }],
+ })
+}
+
+/// Summarizes one checked Orchard-to-Ironwood migration child.
+///
+/// The caller must pass normalized bytes produced by the batch check. This
+/// rejects any detail the compact review cannot display, including funded
+/// Orchard outputs, funded Ironwood spends, and memos on the funded output.
+#[cfg(feature = "cypherpunk")]
+fn summarize_batch_migration_pczt_cypherpunk<P: consensus::Parameters>(
+ params: &P,
+ pczt: &[u8],
+ ufvk_text: &str,
+ seed_fingerprint: &[u8; 32],
+) -> Result<BatchMigrationSummary> {
+ let ufvk = UnifiedFullViewingKey::decode(params, ufvk_text)
+ .map_err(|e| ZcashError::InvalidDataError(e.to_string()))?;
+ let pczt = pczt::parse_pczt(pczt)?;
+ // Reuse ordinary parsing so the summary has the same recovery and display checks.
+ let parsed = pczt::parse::parse_pczt_cypherpunk(params, seed_fingerprint, &ufvk, &pczt)?;
+ require_migration_display_shape(&parsed)?;
+ summarize_migration_actions(&ufvk, &pczt)
+}
+
+/// Parses the first PCZT normally and aggregates later migration children.
+///
+/// All inputs must be normalized bytes produced by the batch check. Returns an
+/// error when the compact representation cannot be built.
+#[cfg(feature = "cypherpunk")]
+pub fn parse_batch_with_migration_summary_cypherpunk<'a, P: consensus::Parameters>(
+ params: &P,
+ first_pczt: &[u8],
+ migration_pczts: impl IntoIterator<Item = &'a [u8]>,
+ ufvk_text: &str,
+ seed_fingerprint: &[u8; 32],
+) -> Result<Vec<ParsedPczt>> {
+ let first = parse_pczt_cypherpunk(params, first_pczt, ufvk_text, seed_fingerprint)?;
+ let mut summary = BatchMigrationSummary::default();
+ let mut has_migrations = false;
+ for child_pczt in migration_pczts {
+ has_migrations = true;
+ let child = summarize_batch_migration_pczt_cypherpunk(
+ params,
+ child_pczt,
+ ufvk_text,
+ seed_fingerprint,
+ )?;
+ summary.add_child(&child)?;
+ }
+ if !has_migrations {
+ return Err(ZcashError::InvalidPczt(
+ "migration review has no child transactions".to_string(),
+ ));
+ }
+
+ Ok(vec![first, summary.to_parsed_pczt()])
+}
+
+/// Rejects children whose ordinary review contains details the compact review
+/// would hide. The accepted shape has one Orchard spend row, one funded
+/// Ironwood output without a memo, and no transparent or Sapling components.
+#[cfg(feature = "cypherpunk")]
+fn require_migration_display_shape(parsed: &ParsedPczt) -> Result<()> {
+ let reject = |what: &str| {
+ Err(ZcashError::InvalidPczt(format!(
+ "migration summary cannot represent {what}; use the per-message review"
+ )))
+ };
+
+ // A migration child must be shielded-only Orchard→Ironwood. A transparent
+ // bundle or any Sapling component is invisible in the amounts-only summary
+ // (a transparent input would additionally understate the displayed fee), so
+ // fall back to the per-message review, which displays them. `get_transparent`
+ // is `Some` only for a non-empty bundle, so shielded-only children pass.
+ if parsed.get_transparent().is_some() {
+ return reject("transparent components");
+ }
+ if parsed.get_has_sapling() {
+ return reject("Sapling components");
+ }
+
+ let no_memo = |to: &ParsedTo| matches!(to.get_memo().as_deref(), None | Some(""));
+
+ let orchard = parsed.get_orchard();
+ let ironwood = parsed.get_ironwood();
+ let orchard_from = orchard
+ .as_ref()
+ .map(|rows| rows.get_from())
+ .unwrap_or_default();
+ let orchard_to = orchard
+ .as_ref()
+ .map(|rows| rows.get_to())
+ .unwrap_or_default();
+ let ironwood_from = ironwood
+ .as_ref()
+ .map(|rows| rows.get_from())
+ .unwrap_or_default();
+ let ironwood_to = ironwood
+ .as_ref()
+ .map(|rows| rows.get_to())
+ .unwrap_or_default();
+
+ if orchard_from.len() != 1 || !ironwood_from.is_empty() {
+ return reject("this spend shape");
+ }
+ // The migrated note is the only Orchard row; a displayable Orchard output
+ // (beyond builder dummies, which the row pass already drops) means the
+ // per-message review had something to show.
+ if !orchard_to.is_empty() {
+ return reject("Orchard outputs");
+ }
+ match ironwood_to.as_slice() {
+ [only] if only.get_amount() != 0 && no_memo(only) => Ok(()),
+ [only] if only.get_amount() != 0 => reject("an output memo"),
+ _ => reject("this output shape"),
+ }
+}
+
#[cfg(test)]
mod additional_tests {
use super::*;
@@ -1709,6 +2098,469 @@ mod tests {
);
}
+ #[test]
+ fn test_batch_migration_summary_accepts_orchard_to_ironwood_child() {
+ let sample = pczt::test_support::sample_migration_pczt();
+
+ let summary = summarize_batch_migration_pczt_cypherpunk(
+ &pczt::test_support::Nu6_3Network,
+ &sample.bytes,
+ &sample.ufvk_text,
+ &sample.seed_fingerprint,
+ )
+ .expect("migration child should summarize");
+
+ assert_eq!(
+ summary,
+ BatchMigrationSummary {
+ migrations: 1,
+ total_input: 1_010_000,
+ total_output: 990_000,
+ total_fee: 20_000,
+ children: vec![BatchMigrationChildSummary {
+ input: 1_010_000,
+ output: 990_000,
+ fee: 20_000,
+ }],
+ }
+ );
+
+ let parsed = summary.to_parsed_pczt();
+ assert_eq!(parsed.get_total_transfer_value(), "0.0099 ZEC");
+ assert_eq!(parsed.get_fee_value(), "0.0002 ZEC");
+ assert_eq!(
+ parsed
+ .get_orchard()
+ .expect("summary should show Orchard inputs")
+ .get_from()
+ .len(),
+ 1
+ );
+ assert_eq!(
+ parsed
+ .get_ironwood()
+ .expect("summary should show Ironwood outputs")
+ .get_to()
+ .len(),
+ 1
+ );
+ assert_eq!(
+ parsed
+ .get_orchard()
+ .expect("summary should show Orchard inputs")
+ .get_from()[0]
+ .get_address()
+ .as_deref(),
+ Some("Migration #1 Orchard note from selected account")
+ );
+ assert_eq!(
+ parsed
+ .get_ironwood()
+ .expect("summary should show Ironwood outputs")
+ .get_to()[0]
+ .get_address(),
+ "Migration #1 wallet Ironwood output"
+ );
+ assert!(parsed
+ .get_ironwood()
+ .expect("summary should show Ironwood outputs")
+ .get_to()[0]
+ .get_is_change());
+ }
+
+ #[test]
+ fn test_batch_migration_summary_aggregates_multiple_children() {
+ let samples = [
+ pczt::test_support::sample_migration_pczt(),
+ pczt::test_support::sample_migration_pczt(),
+ pczt::test_support::sample_migration_pczt(),
+ ];
+ let parsed = parse_batch_with_migration_summary_cypherpunk(
+ &pczt::test_support::Nu6_3Network,
+ &samples[0].bytes,
+ samples[1..].iter().map(|sample| sample.bytes.as_slice()),
+ &samples[0].ufvk_text,
+ &samples[0].seed_fingerprint,
+ )
+ .expect("two migration children should aggregate");
+
+ assert_eq!(parsed.len(), 2);
+ let summary = &parsed[1];
+ assert_eq!(summary.get_total_transfer_value(), "0.0198 ZEC");
+ assert_eq!(summary.get_fee_value(), "0.0004 ZEC");
+ let inputs = summary.get_orchard().unwrap().get_from();
+ let outputs = summary.get_ironwood().unwrap().get_to();
+ assert_eq!(inputs.len(), 2);
+ assert!(inputs.iter().all(|input| input.get_amount() == 1_010_000));
+ assert_eq!(outputs.len(), 2);
+ assert!(outputs.iter().all(|output| output.get_amount() == 990_000));
+ }
+
+ #[test]
+ fn test_batch_migration_summary_requires_a_child() {
+ let first = pczt::test_support::sample_migration_pczt();
+ assert_invalid_pczt_message(
+ parse_batch_with_migration_summary_cypherpunk(
+ &pczt::test_support::Nu6_3Network,
+ &first.bytes,
+ core::iter::empty(),
+ &first.ufvk_text,
+ &first.seed_fingerprint,
+ ),
+ "migration review has no child transactions",
+ );
+ }
+
+ // A memo on the funded output forces fallback to the review that displays it.
+ #[test]
+ fn test_batch_migration_summary_rejects_memo_carrying_output() {
+ 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 summary_err = summarize_batch_migration_pczt_cypherpunk(
+ &pczt::test_support::Nu6_3Network,
+ &sample.bytes,
+ &sample.ufvk_text,
+ &sample.seed_fingerprint,
+ )
+ .expect_err("summary must refuse a memo it cannot render");
+ assert!(
+ matches!(&summary_err, ZcashError::InvalidPczt(message) if message.contains("per-message review")),
+ "expected a display-shape rejection, got {summary_err:?}"
+ );
+
+ // Parity: the fallback per-message review shows the memo.
+ let parsed = check_and_parse_batch_pczt_cypherpunk(
+ &pczt::test_support::Nu6_3Network,
+ &sample.bytes,
+ &sample.ufvk_text,
+ &sample.seed_fingerprint,
+ 0,
+ )
+ .expect("per-message review must accept the memo-carrying child");
+ let shown_memo = parsed
+ .get_ironwood()
+ .expect("migration must show Ironwood outputs")
+ .get_to()
+ .first()
+ .expect("migration must show the real output")
+ .get_memo();
+ assert_eq!(shown_memo.as_deref(), Some("covert note"));
+
+ let first = pczt::test_support::sample_migration_pczt();
+ assert!(parse_batch_with_migration_summary_cypherpunk(
+ &pczt::test_support::Nu6_3Network,
+ &first.bytes,
+ core::iter::once(sample.bytes.as_slice()),
+ &sample.ufvk_text,
+ &sample.seed_fingerprint,
+ )
+ .is_err());
+ }
+
+ #[test]
+ fn test_batch_migration_summary_rejects_foreign_funded_output() {
+ let sample = pczt::test_support::sample_migration_pczt_to_account(1);
+
+ let summary_err = summarize_batch_migration_pczt_cypherpunk(
+ &pczt::test_support::Nu6_3Network,
+ &sample.bytes,
+ &sample.ufvk_text,
+ &sample.seed_fingerprint,
+ )
+ .expect_err("summary must refuse a foreign funded output");
+ assert!(matches!(
+ &summary_err,
+ ZcashError::InvalidPczt(message) if message.contains("not wallet-owned")
+ ));
+
+ let parsed = check_and_parse_batch_pczt_cypherpunk(
+ &pczt::test_support::Nu6_3Network,
+ &sample.bytes,
+ &sample.ufvk_text,
+ &sample.seed_fingerprint,
+ 0,
+ )
+ .expect("ordinary review should show a foreign output");
+ let outputs = parsed.get_ironwood().unwrap().get_to();
+ let output = &outputs[0];
+ assert_eq!(output.get_amount(), 990_000);
+ assert!(!output.get_is_change());
+ }
+
+ #[test]
+ fn test_batch_migration_summary_ignores_dummy_equivalent_zero_output() {
+ use zcash_vendor::zcash_protocol::memo::MemoBytes;
+
+ let sample = pczt::test_support::sample_migration_pczt_with_zero_output(
+ MemoBytes::from_bytes(b"hidden dummy memo").unwrap(),
+ false,
+ );
+ let summary = summarize_batch_migration_pczt_cypherpunk(
+ &pczt::test_support::Nu6_3Network,
+ &sample.bytes,
+ &sample.ufvk_text,
+ &sample.seed_fingerprint,
+ )
+ .expect("dummy-equivalent zero output should not block the summary");
+ assert_eq!(summary.total_output, 990_000);
+ assert_eq!(summary.total_fee, 20_000);
+
+ let parsed = check_and_parse_batch_pczt_cypherpunk(
+ &pczt::test_support::Nu6_3Network,
+ &sample.bytes,
+ &sample.ufvk_text,
+ &sample.seed_fingerprint,
+ 0,
+ )
+ .expect("ordinary review should accept the zero output");
+ let outputs = parsed.get_ironwood().unwrap().get_to();
+ assert_eq!(outputs.len(), 1);
+ assert_eq!(outputs[0].get_amount(), 990_000);
+ assert!(outputs[0].get_memo().is_none());
+ }
+
+ #[test]
+ fn test_batch_migration_summary_falls_back_for_displayable_zero_output() {
+ use zcash_vendor::zcash_protocol::memo::MemoBytes;
+
+ let sample = pczt::test_support::sample_migration_pczt_with_zero_output(
+ MemoBytes::from_bytes(b"visible zero memo").unwrap(),
+ true,
+ );
+ let summary_err = summarize_batch_migration_pczt_cypherpunk(
+ &pczt::test_support::Nu6_3Network,
+ &sample.bytes,
+ &sample.ufvk_text,
+ &sample.seed_fingerprint,
+ )
+ .expect_err("displayable zero output should force ordinary review");
+ assert!(matches!(
+ &summary_err,
+ ZcashError::InvalidPczt(message) if message.contains("output shape")
+ ));
+
+ let parsed = check_and_parse_batch_pczt_cypherpunk(
+ &pczt::test_support::Nu6_3Network,
+ &sample.bytes,
+ &sample.ufvk_text,
+ &sample.seed_fingerprint,
+ 0,
+ )
+ .expect("ordinary review should show the zero output");
+ let outputs = parsed.get_ironwood().unwrap().get_to();
+ assert_eq!(outputs.len(), 2);
+ let zero = outputs
+ .iter()
+ .find(|output| output.get_amount() == 0)
+ .expect("zero output should be displayed");
+ assert_eq!(zero.get_memo().as_deref(), Some("visible zero memo"));
+ }
+
+ // Transparent or Sapling components force fallback because the compact summary
+ // cannot display them or include transparent values in its fee.
+ #[test]
+ fn test_migration_display_shape_rejects_transparent_and_sapling() {
+ use crate::pczt::structs::ParsedTransparent;
+
+ // The shielded-only shape the summary can represent.
+ let orchard = ParsedOrchard::new(
+ vec![ParsedFrom::new(
+ None,
+ "0.0101 ZEC".to_string(),
+ 1_010_000,
+ true,
+ )],
+ vec![],
+ );
+ let ironwood = ParsedOrchard::new(
+ vec![],
+ vec![ParsedTo::new(
+ "wallet Ironwood output".to_string(),
+ "0.0099 ZEC".to_string(),
+ 990_000,
+ true,
+ false,
+ None,
+ )],
+ );
+ let build = |transparent: Option<ParsedTransparent>, has_sapling: bool| {
+ ParsedPczt::new(
+ transparent,
+ Some(orchard.clone()),
+ Some(ironwood.clone()),
+ "0.0099 ZEC".to_string(),
+ "0.0002 ZEC".to_string(),
+ has_sapling,
+ )
+ };
+
+ // Control: the pure shielded migration folds into the summary.
+ require_migration_display_shape(&build(None, false))
+ .expect("a shielded-only Orchard->Ironwood child must still summarize");
+
+ // A wallet-owned transparent input the amounts-only summary would hide
+ // (its value silently dropped from the fee) must force fallback.
+ let transparent = ParsedTransparent::new(
+ vec![ParsedFrom::new(
+ Some("t1wallet".to_string()),
+ "0.0005 ZEC".to_string(),
+ 50_000,
+ true,
+ )],
+ vec![],
+ );
+ assert!(
+ matches!(
+ require_migration_display_shape(&build(Some(transparent), false)),
+ Err(ZcashError::InvalidPczt(message)) if message.contains("transparent components")
+ ),
+ "a transparent component must fall back to the per-message review",
+ );
+
+ // A Sapling component must likewise force fallback.
+ assert!(
+ matches!(
+ require_migration_display_shape(&build(None, true)),
+ Err(ZcashError::InvalidPczt(message)) if message.contains("Sapling components")
+ ),
+ "a Sapling component must fall back to the per-message review",
+ );
+ }
+
+ // An undecryptable funded output must fail both compact and ordinary review.
+ #[test]
+ fn test_batch_migration_summary_rejects_undecryptable_ironwood_output() {
+ use zcash_vendor::pczt::Pczt;
+
+ /// Flips a byte inside the first verbatim occurrence of `needle`.
+ fn corrupt_first_occurrence(haystack: &mut [u8], needle: &[u8]) -> bool {
+ if needle.is_empty() || needle.len() > haystack.len() {
+ return false;
+ }
+ for start in 0..=haystack.len() - needle.len() {
+ if &haystack[start..start + needle.len()] == needle {
+ haystack[start + needle.len() / 2] ^= 0xff;
+ return true;
+ }
+ }
+ false
+ }
+
+ let sample = pczt::test_support::sample_migration_pczt();
+
+ // Extract the funded Ironwood output's encrypted ciphertext bytes.
+ let enc_ciphertext = {
+ let pczt = Pczt::parse(&sample.bytes).expect("sample PCZT should parse");
+ pczt.ironwood()
+ .actions()
+ .iter()
+ .find(|action| matches!(action.output().value(), Some(value) if *value != 0))
+ .expect("migration child must contain a non-zero Ironwood output")
+ .output()
+ .enc_ciphertext()
+ .clone()
+ .into_encrypted()
+ .expect("the sample's Ironwood output carries a full enc_ciphertext")
+ };
+
+ // Corrupt only the ciphertext: cmx, cv_net, the value balance, and the
+ // plaintext recipient are all untouched, so every other check still
+ // passes and only decryption/recoverability fails.
+ let mut corrupted = sample.bytes.clone();
+ assert!(
+ corrupt_first_occurrence(&mut corrupted, &enc_ciphertext),
+ "sample must embed the Ironwood output enc_ciphertext verbatim"
+ );
+ assert!(
+ Pczt::parse(&corrupted).is_ok(),
+ "corruption must keep the PCZT structurally well-formed"
+ );
+
+ let summary_err = summarize_batch_migration_pczt_cypherpunk(
+ &pczt::test_support::Nu6_3Network,
+ &corrupted,
+ &sample.ufvk_text,
+ &sample.seed_fingerprint,
+ )
+ .expect_err("summary must reject a migration child with an undecryptable output");
+ assert!(
+ matches!(&summary_err, ZcashError::InvalidPczt(message) if message.contains("undecryptable")),
+ "expected an undecryptable-output rejection, got {summary_err:?}"
+ );
+
+ // The ordinary review must enforce the same output recovery rule.
+ assert!(
+ matches!(
+ check_and_parse_batch_pczt_cypherpunk(
+ &pczt::test_support::Nu6_3Network,
+ &corrupted,
+ &sample.ufvk_text,
+ &sample.seed_fingerprint,
+ 0,
+ ),
+ Err(ZcashError::InvalidPczt(message)) if message.contains("undecryptable")
+ ),
+ "ordinary per-message review must also reject the undecryptable output"
+ );
+ }
+
+ #[test]
+ fn test_batch_migration_summary_accepts_optional_spend_fvk() {
+ use zcash_vendor::pczt::{roles::redactor::Redactor, Pczt};
+
+ let sample = pczt::test_support::sample_migration_pczt();
+ let pczt = Pczt::parse(&sample.bytes).expect("sample PCZT should parse");
+ let redacted = Redactor::new(pczt)
+ .redact_orchard_with(|mut r| {
+ r.redact_actions(|mut ar| {
+ ar.clear_spend_fvk();
+ });
+ })
+ .redact_ironwood_with(|mut r| {
+ r.redact_actions(|mut ar| {
+ ar.clear_spend_fvk();
+ });
+ })
+ .finish()
+ .serialize()
+ .expect("redacted PCZT should serialize");
+
+ let summary = summarize_batch_migration_pczt_cypherpunk(
+ &pczt::test_support::Nu6_3Network,
+ &redacted,
+ &sample.ufvk_text,
+ &sample.seed_fingerprint,
+ )
+ .expect("redacted migration child should summarize");
+
+ assert_eq!(summary.migrations, 1);
+ assert_eq!(summary.total_input, 1_010_000);
+ assert_eq!(summary.total_output, 990_000);
+ assert_eq!(summary.total_fee, 20_000);
+
+ let signed = sign_checked_batch_pczt(
+ &pczt::test_support::Nu6_3Network,
+ &redacted,
+ &sample.seed,
+ &sample.seed_fingerprint,
+ 0,
+ )
+ .expect("request redacted only by optional spend FVK should sign");
+ let parsed = Pczt::parse(&signed).expect("signed PCZT should parse");
+ assert!(
+ parsed
+ .orchard()
+ .actions()
+ .iter()
+ .any(|action| action.spend().spend_auth_sig().is_some()),
+ "redacted migration request must still produce an Orchard spend signature"
+ );
+ }
+
#[test]
fn test_check_resolves_compact_pczt_and_signs() {
use zcash_vendor::pczt::roles::redactor::Redactor;
diff --git a/rust/apps/zcash/src/pczt/mod.rs b/rust/apps/zcash/src/pczt/mod.rs
index a0a7d33..46df114 100644
--- a/rust/apps/zcash/src/pczt/mod.rs
+++ b/rust/apps/zcash/src/pczt/mod.rs
@@ -200,7 +200,7 @@ pub(crate) mod test_support {
use zcash_vendor::{
orchard,
pczt::Pczt,
- zcash_keys::keys::UnifiedFullViewingKey,
+ zcash_keys::keys::{UnifiedAddressRequest, UnifiedFullViewingKey},
zcash_protocol::{
consensus::{BranchId, MainNetwork, Parameters},
memo::{Memo, MemoBytes},
@@ -423,10 +423,35 @@ pub(crate) mod test_support {
}
}
- // Orchard spend -> Ironwood output: a cross-pool migration, the message type
- // the real batch uses (and the one never exercised on-device). Mirrors
- // sample_ironwood_pczt but the *spent* note is an Orchard note.
+ // Orchard spend -> Ironwood output, matching one migration child in a batch.
pub(crate) fn sample_migration_pczt() -> SamplePczt {
+ sample_migration_pczt_with_options(0, MemoBytes::empty(), None)
+ }
+
+ /// Builds a migration whose funded output carries the given memo.
+ pub(crate) fn sample_migration_pczt_with_output_memo(output_memo: MemoBytes) -> SamplePczt {
+ sample_migration_pczt_with_options(0, output_memo, None)
+ }
+
+ /// Builds a migration whose funded output belongs to the given account.
+ pub(crate) fn sample_migration_pczt_to_account(output_account: u32) -> SamplePczt {
+ sample_migration_pczt_with_options(output_account, MemoBytes::empty(), None)
+ }
+
+ /// Adds a zero-value output, optionally marked for ordinary display.
+ pub(crate) fn sample_migration_pczt_with_zero_output(
+ memo: MemoBytes,
+ displayable: bool,
+ ) -> SamplePczt {
+ sample_migration_pczt_with_options(0, MemoBytes::empty(), Some((memo, displayable)))
+ }
+
+ /// Builds a migration sample with a configurable funded recipient and optional zero output.
+ fn sample_migration_pczt_with_options(
+ output_account: u32,
+ output_memo: MemoBytes,
+ zero_output: Option<(MemoBytes, bool)>,
+ ) -> SamplePczt {
let params = Nu6_3Network;
let seed = [7u8; 32];
let ufvk_text = derive_ufvk(¶ms, &seed, "m/32'/133'/0'").unwrap();
@@ -434,7 +459,21 @@ pub(crate) mod test_support {
let orchard_fvk = ufvk.orchard().unwrap().clone();
let orchard_ivk = orchard_fvk.to_ivk(orchard::keys::Scope::External);
let orchard_ovk = orchard_fvk.to_ovk(orchard::keys::Scope::External);
- let recipient = orchard_fvk.address_at(0u32, orchard::keys::Scope::External);
+ let spend_recipient = orchard_fvk.address_at(0u32, orchard::keys::Scope::External);
+ let output_ufvk_text = derive_ufvk(
+ ¶ms,
+ &seed,
+ &alloc::format!("m/32'/133'/{output_account}'"),
+ )
+ .unwrap();
+ let output_ufvk = UnifiedFullViewingKey::decode(¶ms, &output_ufvk_text).unwrap();
+ let output_fvk = output_ufvk.orchard().unwrap();
+ let recipient = output_fvk.address_at(0u32, orchard::keys::Scope::External);
+ let output_user_address = output_ufvk
+ .default_address(UnifiedAddressRequest::AllAvailableKeys)
+ .unwrap()
+ .0
+ .encode(¶ms);
// The Orchard note being migrated: output (990_000) + cross-pool fee (20_000),
// so there is no change output.
@@ -448,7 +487,12 @@ pub(crate) mod test_support {
)
.expect("spends-disabled flags are valid for a coinbase bundle");
orchard_builder
- .add_output(None, recipient, value, Memo::Empty.encode().into_bytes())
+ .add_output(
+ None,
+ spend_recipient,
+ value,
+ Memo::Empty.encode().into_bytes(),
+ )
.unwrap();
let (bundle, meta) = orchard_builder.build::<i64>(&mut OsRng).unwrap().unwrap();
let action = bundle
@@ -496,17 +540,32 @@ pub(crate) mod test_support {
Some(orchard_ovk),
recipient,
Zatoshis::const_from_u64(990_000),
- MemoBytes::empty(),
+ output_memo,
)
.unwrap();
+ if let Some((memo, _)) = zero_output.as_ref() {
+ builder
+ .add_ironwood_output::<zip317::FeeRule>(
+ None,
+ recipient,
+ Zatoshis::ZERO,
+ memo.clone(),
+ )
+ .unwrap();
+ }
let PcztResult {
pczt_parts,
orchard_meta,
+ ironwood_meta,
..
} = builder
.build_for_pczt(OsRng, &zip317::FeeRule::standard())
.unwrap();
let spend_action_index = orchard_meta.spend_action_index(0).unwrap();
+ let displayable_zero_action = match zero_output.as_ref() {
+ Some((_, true)) => Some(ironwood_meta.output_action_index(1).unwrap()),
+ _ => None,
+ };
let seed_fingerprint = calculate_seed_fingerprint(&seed).unwrap();
let derivation = orchard::pczt::Zip32Derivation::parse(
seed_fingerprint,
@@ -526,6 +585,19 @@ pub(crate) mod test_support {
})
.unwrap()
.finish();
+ let pczt = if let Some(action_index) = displayable_zero_action {
+ Updater::new(pczt)
+ .update_ironwood_with(|mut bundle| {
+ bundle.update_action_with(action_index, |mut action| {
+ action.set_output_user_address(output_user_address);
+ Ok(())
+ })
+ })
+ .unwrap()
+ .finish()
+ } else {
+ pczt
+ };
SamplePczt {
bytes: pczt.serialize().unwrap(),
diff --git a/rust/apps/zcash/src/pczt/parse.rs b/rust/apps/zcash/src/pczt/parse.rs
index a33bc7d..f37fab7 100644
--- a/rust/apps/zcash/src/pczt/parse.rs
+++ b/rust/apps/zcash/src/pczt/parse.rs
@@ -81,7 +81,7 @@ fn map_transparent_verifier_error(
}
}
-fn format_zec_value(value: f64) -> String {
+pub(crate) fn format_zec_value(value: f64) -> String {
let zec_value = format!("{:.8}", value / ZEC_DIVIDER as f64);
let zec_value = zec_value
.trim_end_matches('0')
diff --git a/rust/rust_c/src/zcash/mod.rs b/rust/rust_c/src/zcash/mod.rs
index cacf30d..58187b3 100644
--- a/rust/rust_c/src/zcash/mod.rs
+++ b/rust/rust_c/src/zcash/mod.rs
@@ -388,7 +388,18 @@ pub unsafe extern "C" fn parse_zcash_batch_tx_cypherpunk(
let seed_fingerprint = extract_array!(seed_fingerprint, u8, 32);
let seed_fingerprint = seed_fingerprint.try_into().unwrap();
- let mut display_items = Vec::new();
+ if batch.get_messages().len() > 1 {
+ // The normalized bytes already passed the batch check. Try the compact
+ // review; if it cannot be built, use the ordinary per-message review below.
+ if let Ok(display_items) =
+ parse_zcash_batch_as_first_plus_migrations(&batch, &ufvk_text, seed_fingerprint)
+ {
+ return TransactionParseResult::success(DisplayZcashBatch::from(display_items).c_ptr())
+ .c_ptr();
+ }
+ }
+
+ let mut parsed_items = Vec::new();
for message in batch.get_messages() {
match app_zcash::parse_pczt_cypherpunk(
&MainNetwork,
@@ -396,14 +407,47 @@ pub unsafe extern "C" fn parse_zcash_batch_tx_cypherpunk(
&ufvk_text,
seed_fingerprint,
) {
- Ok(pczt) => display_items.push(DisplayPczt::from(&pczt)),
+ Ok(pczt) => parsed_items.push(pczt),
Err(e) => return TransactionParseResult::from(e).c_ptr(),
}
}
+ // FFI display structs leak if dropped (freed via free_TransactionParseResult_*,
+ // not Drop), so build them only after every message has parsed.
+ let display_items: Vec<DisplayPczt> = parsed_items.iter().map(DisplayPczt::from).collect();
TransactionParseResult::success(DisplayZcashBatch::from(display_items).c_ptr()).c_ptr()
}
+/// Parses message 0 normally and folds later migration children into one summary.
+/// The caller uses errors to select the ordinary per-message review.
+#[cfg(feature = "cypherpunk")]
+fn parse_zcash_batch_as_first_plus_migrations(
+ batch: &ZcashSignBatch,
+ ufvk_text: &str,
+ seed_fingerprint: &[u8; 32],
+) -> app_zcash::errors::Result<Vec<DisplayPczt>> {
+ let messages = batch.get_messages();
+ // The caller requires at least two messages, so message 0 is present.
+ debug_assert!(messages.len() > 1);
+ let first_message = &messages[0];
+ let parsed_items = app_zcash::parse_batch_with_migration_summary_cypherpunk(
+ &MainNetwork,
+ first_message.get_payload(),
+ messages
+ .iter()
+ .skip(1)
+ .map(|message| message.get_payload().as_slice()),
+ ufvk_text,
+ seed_fingerprint,
+ )?;
+
+ // Materialize the FFI display structs only after every fallible step: their
+ // nested FFI-owned allocations are freed through free_TransactionParseResult_*,
+ // not Drop, so building one before an Err (which sends the caller down the
+ // per-message fallback) would leak it on every non-migration batch review.
+ Ok(parsed_items.iter().map(DisplayPczt::from).collect())
+}
+
#[cfg(feature = "cypherpunk")]
unsafe fn sign_zcash_batch_tx_cypherpunk_dynamic(
checked_batch: Ptr<ZcashCheckedPczt>,
diff --git a/src/ui/gui_chain/multi/gui_zcash.c b/src/ui/gui_chain/multi/gui_zcash.c
index 8bfe9f7..0ecea04 100644
--- a/src/ui/gui_chain/multi/gui_zcash.c
+++ b/src/ui/gui_chain/multi/gui_zcash.c
@@ -389,6 +389,8 @@ UREncodeResult *GuiSignZcashCypherpunkWithSeed(void *data,
memset_s(seed, sizeof(seed), 0, sizeof(seed));
ClearSecretCache();
+ // Signing can exceed the lock timeout; restart it before restoring auto-lock.
+ ClearLockScreenTime();
SetLockScreen(enable);
return encodeResult;
}
diff --git a/src/ui/gui_model/gui_model.c b/src/ui/gui_model/gui_model.c
index fa55e56..2a42843 100644
--- a/src/ui/gui_model/gui_model.c
+++ b/src/ui/gui_model/gui_model.c
@@ -1456,6 +1456,8 @@ static int32_t ModelParseTransaction(const void *indata, uint32_t inDataLen, Bac
GuiApiEmitSignal(SIG_TRANSACTION_PARSE_FAIL, parsedResult, sizeof(parsedResult));
}
GuiApiEmitSignal(SIG_HIDE_TRANSACTION_LOADING, NULL, 0);
+ // Parsing can exceed the lock timeout, so restart it before re-enabling auto-lock.
+ ClearLockScreenTime();
SetPageLockScreen(true);
return SUCCESS_CODE;
}
diff --git a/src/ui/gui_widgets/gui_transaction_signature_widgets.c b/src/ui/gui_widgets/gui_transaction_signature_widgets.c
index 9d53fc3..b9b42c7 100644
--- a/src/ui/gui_widgets/gui_transaction_signature_widgets.c
+++ b/src/ui/gui_widgets/gui_transaction_signature_widgets.c
@@ -60,6 +60,7 @@ void GuiTransactionSignatureRefresh(void)
void GuiTransactionSignatureHandleURGenerate(char *data, uint16_t len)
{
GuiAnimantingQRCodeFirstUpdate(data, len);
+ ClearLockScreenTime();
}
void GuiTransactionSignatureHandleURUpdate(char *data, uint16_t len)
@@ -70,6 +71,7 @@ void GuiTransactionSignatureHandleURUpdate(char *data, uint16_t len)
void GuiTransactionSignatureHandleURGenerateFail(void *param)
{
GuiPendingHintBoxRemove();
+ ClearLockScreenTime();
UREncodeResult *result = NULL;
if (param != NULL) {
result = *(UREncodeResult **)param;
Why this scored 33/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.