fix(zcash): skip finalized dummy spends during signing
What changed, and why it matters
This commit fixes a bug in the Keystone hardware wallet's Zcash signing code. When signing a batch of Zcash transactions that include 'dummy' placeholder spends (zero-value decoy actions used for privacy), the signer could get stuck or fail because it tried to re-sign a dummy spend whose signature had already been finalized and then stripped for transport. The fix tells the signer to skip these finalized zero-value dummy spends, allowing the real spend to be signed normally. The changelog frames it as a fix for batch signing with finalized zero-value dummy spends.
Treat as a low-to-moderate reliability/defensive fix. Review whether the skip condition could ever apply to a non-dummy, wallet-controlled zero-value spend that legitimately has alpha set; the current condition (value==0 && alpha.is_none()) appears narrowly scoped. Verify the new regression test covers both the failing and fixed behavior. No immediate emergency response is indicated, but include in the next firmware release because it unblocks Zcash batch signing workflows.
Security signals we found
Zcash privacy-spend signing bypass for finalized dummy actions
Batch-transport redaction state reproduced in regression test
Potential signing failure / denial-of-service for Zcash PCZT transactions with dummy spends
No cryptographic bypass of real spends; real spend still signed in test
Evidence from the diff
In rust/apps/zcash/src/pczt/sign.rs, the Orchard/Ironwood spend signing loop now checks whether a spend has value 0 and no alpha (α). If so, it returns Ok(()) early, skipping signature generation for that action. The accompanying test simulates the post-IO-finalizer, redacted state produced by a batch transport: dummy_sk is consumed, spend_auth_sig and alpha are cleared, but the spend derivation remains. Before the fix, this state likely caused signing to fail or stall because the code expected either a signable spend (with value/alpha) or a fully unspecified one (value None). The Cargo.toml change adds the io-finalizer feature to the pczt dev-dependency so the test can call finalize_io(). The CHANGELOG describes the user-visible symptom as fixed batch signing with finalized zero-value dummy spends.
Changed components
rust/apps/zcash/src/pczt/sign.rsZcash PCZT (Partially Created Zcash Transaction) signerOrchard/Ironwood spend authorization signature pathInspect captured patch +81 / −1
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3654bbb..886c206 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,8 @@
1. Fixed stalled Zcash signing when response QR generation fails
+2. Fixed Zcash batch signing with finalized zero-value dummy spends
+
## 2.5.0(2026-6-29)
diff --git a/rust/apps/zcash/Cargo.toml b/rust/apps/zcash/Cargo.toml
index 70a7f09..8942b91 100644
--- a/rust/apps/zcash/Cargo.toml
+++ b/rust/apps/zcash/Cargo.toml
@@ -27,7 +27,7 @@ zeroize = { workspace = true }
[dev-dependencies]
keystore = { path = "../../keystore" }
-pczt = { version = "0.7", default-features = false, features = ["orchard", "sapling", "transparent", "zcp-builder"] }
+pczt = { version = "0.7", default-features = false, features = ["io-finalizer", "orchard", "sapling", "transparent", "zcp-builder"] }
zcash_primitives = { version = "0.29.0-pre.0", default-features = false, features = ["circuits", "test-dependencies", "transparent-inputs"] }
incrementalmerkletree = { version = "0.8.2", default-features = false }
shardtree = "0.6.2"
diff --git a/rust/apps/zcash/src/pczt/sign.rs b/rust/apps/zcash/src/pczt/sign.rs
index 366d6d1..31d9c2f 100644
--- a/rust/apps/zcash/src/pczt/sign.rs
+++ b/rust/apps/zcash/src/pczt/sign.rs
@@ -333,6 +333,15 @@ impl PcztSigner for SeedSigner<'_> {
}
}
}
+ // Batch transport clears `dummy_sk`, the finalized dummy signature, and
+ // `alpha`; the wallet retains that signature for extraction. A zero-value
+ // spend with no `alpha` cannot be signed here and authorizes no value, so
+ // skip it. Wallet-controlled zero-value spends retain `alpha` and sign.
+ if matches!(action.spend().value(), Some(value) if value.inner() == 0)
+ && action.spend().alpha().is_none()
+ {
+ return Ok(());
+ }
if action.spend().value().is_none() {
return Ok(());
}
@@ -862,6 +871,75 @@ mod tests {
);
}
+ #[test]
+ fn test_sign_pczt_skips_finalized_redacted_dummy_spend() {
+ let sample = crate::pczt::test_support::sample_ironwood_pczt();
+ let pczt = crate::pczt::test_support::ironwood_pczt_with_dummy_spend_derivation(
+ &sample.bytes,
+ sample.seed_fingerprint,
+ crate::pczt::test_support::orchard_spend_path_for_account(0),
+ );
+ let pczt = Pczt::parse(&pczt).unwrap();
+ let mut dummy_indices = Vec::new();
+ let pczt = zcash_vendor::pczt::roles::verifier::Verifier::new(pczt)
+ .with_ironwood::<(), _>(|bundle| {
+ dummy_indices.extend(bundle.actions().iter().enumerate().filter_map(
+ |(index, action)| {
+ matches!(action.spend().value().map(|value| value.inner()), Some(0))
+ .then_some(index)
+ },
+ ));
+ Ok(())
+ })
+ .unwrap()
+ .finish();
+ assert!(!dummy_indices.is_empty());
+ let pczt = zcash_vendor::pczt::roles::io_finalizer::IoFinalizer::new(pczt)
+ .finalize_io()
+ .unwrap();
+ let pczt = zcash_vendor::pczt::roles::verifier::Verifier::new(pczt)
+ .with_ironwood::<(), _>(|bundle| {
+ for index in &dummy_indices {
+ let spend = bundle.actions()[*index].spend();
+ assert!(spend.dummy_sk().is_none());
+ assert!(spend.spend_auth_sig().is_some());
+ assert!(spend.alpha().is_some());
+ assert!(spend.zip32_derivation().is_some());
+ }
+ Ok(())
+ })
+ .unwrap()
+ .finish();
+
+ // Match Vizor's batch transport after IO finalization: the finalizer
+ // consumed dummy_sk, then redaction removed the dummy signature and
+ // alpha while retaining the output action's spend derivation.
+ let pczt = Redactor::new(pczt)
+ .redact_ironwood_with(|mut bundle| {
+ bundle.redact_actions(|mut action| {
+ action.clear_spend_fvk();
+ action.clear_spend_auth_sig();
+ });
+ for index in dummy_indices {
+ bundle.redact_action(index, |mut action| {
+ action.clear_spend_alpha();
+ });
+ }
+ })
+ .finish();
+
+ let signed = sign_pczt(pczt, &sample.seed)
+ .expect("finalized redacted dummy spend must not block the real signature");
+ let signed_count = Pczt::parse(&signed)
+ .unwrap()
+ .ironwood()
+ .actions()
+ .iter()
+ .filter(|action| action.spend().spend_auth_sig().is_some())
+ .count();
+ assert_eq!(signed_count, 1, "only the real spend should be signed");
+ }
+
#[test]
fn test_sign_pczt_orchard_change_output_spend() {
let sample = crate::pczt::test_support::sample_orchard_change_pczt();
Why this scored 43/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.