Skip to main content

pczt/
lib.rs

1//! The Partially Created Zcash Transaction (PCZT) format.
2//!
3//! This format enables splitting up the logical steps of creating a Zcash transaction
4//! across distinct entities. The entity roles roughly match those specified in
5//! [BIP 174: Partially Signed Bitcoin Transaction Format] and [BIP 370: PSBT Version 2],
6//! with additional Zcash-specific roles.
7//!
8//! [BIP 174: Partially Signed Bitcoin Transaction Format]: https://github.com/bitcoin/bips/blob/master/bip-0174.mediawiki
9//! [BIP 370: PSBT Version 2]: https://github.com/bitcoin/bips/blob/master/bip-0370.mediawiki
10//!
11#![cfg_attr(feature = "std", doc = "## Feature flags")]
12#![cfg_attr(feature = "std", doc = document_features::document_features!())]
13//!
14
15#![no_std]
16#![cfg_attr(docsrs, feature(doc_cfg))]
17#![cfg_attr(docsrs, doc(auto_cfg))]
18// Catch documentation errors caused by code changes.
19#![deny(rustdoc::broken_intra_doc_links)]
20
21#[macro_use]
22extern crate alloc;
23// The crate itself needs only `alloc`; the unit tests lean on `proptest`, which is a `std` crate.
24#[cfg(test)]
25extern crate std;
26
27use alloc::vec::Vec;
28
29use getset::Getters;
30
31use zcash_protocol::PoolType;
32#[cfg(any(feature = "io-finalizer", feature = "signer", feature = "tx-extractor"))]
33use zcash_protocol::constants::{V6_TX_VERSION, V6_VERSION_GROUP_ID};
34#[cfg(all(
35    any(feature = "io-finalizer", feature = "signer", feature = "tx-extractor"),
36    zcash_unstable = "nutachyon"
37))]
38use zcash_protocol::constants::{V7_TX_VERSION, V7_VERSION_GROUP_ID};
39#[cfg(any(feature = "io-finalizer", feature = "signer", feature = "tx-extractor"))]
40use {
41    common::{Global, determine_lock_time},
42    zcash_primitives::transaction::{Authorization, TransactionData, TxVersion},
43    zcash_protocol::{
44        consensus::{BranchId, OrchardProtocolRevision},
45        constants::{V5_TX_VERSION, V5_VERSION_GROUP_ID},
46    },
47};
48
49#[cfg(any(feature = "io-finalizer", feature = "signer"))]
50use zcash_primitives::transaction::sighash::{PrecomputedSighashData, SignableInput};
51
52pub mod roles;
53
54pub mod common;
55pub mod orchard;
56pub mod sapling;
57pub mod transparent;
58
59pub(crate) const MAGIC_BYTES: &[u8; 4] = b"PCZT";
60pub(crate) const PCZT_VERSION_1: u32 = 1;
61pub(crate) const PCZT_VERSION_2: u32 = 2;
62
63const VERSIONED_HEADER_LEN: usize = 8;
64
65pub(crate) enum HeaderParseError {
66    InvalidMagic,
67    TooShort,
68}
69
70pub(crate) fn parse_header<'a>(
71    bytes: &'a [u8],
72    magic: &[u8; 4],
73) -> Result<(u32, &'a [u8]), HeaderParseError> {
74    if bytes.len() < VERSIONED_HEADER_LEN {
75        return Err(HeaderParseError::TooShort);
76    }
77    if &bytes[..4] != magic {
78        return Err(HeaderParseError::InvalidMagic);
79    }
80
81    let version = u32::from_le_bytes(bytes[4..8].try_into().unwrap());
82    Ok((version, &bytes[VERSIONED_HEADER_LEN..]))
83}
84
85pub(crate) fn serialize_header(magic: &[u8; 4], version: u32) -> Vec<u8> {
86    let mut bytes = Vec::with_capacity(VERSIONED_HEADER_LEN);
87    bytes.extend_from_slice(magic);
88    bytes.extend_from_slice(&version.to_le_bytes());
89    bytes
90}
91
92/// Parses a PCZT from its encoding.
93pub fn parse(bytes: &[u8]) -> Result<Pczt, ParseError> {
94    Pczt::parse(bytes)
95}
96
97/// A partially-created Zcash transaction.
98#[derive(Clone, Debug, Getters)]
99pub struct Pczt {
100    /// Global fields that are relevant to the transaction as a whole.
101    #[getset(get = "pub")]
102    pub(crate) global: common::Global,
103
104    //
105    // Protocol-specific fields.
106    //
107    // Unlike the `TransactionData` type in `zcash_primitives`, these are not optional.
108    // This is because a PCZT does not always contain a semantically-valid transaction,
109    // and there may be phases where we need to store protocol-specific metadata before
110    // it has been determined whether there are protocol-specific inputs or outputs.
111    //
112    #[getset(get = "pub")]
113    pub(crate) transparent: transparent::Bundle,
114    #[getset(get = "pub")]
115    pub(crate) sapling: sapling::Bundle,
116    #[getset(get = "pub")]
117    pub(crate) orchard: orchard::Bundle,
118    #[getset(get = "pub")]
119    pub(crate) ironwood: orchard::Bundle,
120}
121
122/// Types and operations for the v1 Pczt encoding.
123pub mod v1 {
124    use alloc::vec::Vec;
125    use serde::{Deserialize, Serialize};
126
127    use crate::{common, orchard, sapling, transparent};
128
129    /// The in-memory type used for derived serialization of the v1 Pczt encoding.
130    #[derive(Clone, Debug, Serialize, Deserialize)]
131    pub struct Pczt {
132        global: common::Global,
133        transparent: transparent::Bundle,
134        sapling: sapling::v1::Bundle,
135        orchard: orchard::v1::Bundle,
136    }
137
138    impl Pczt {
139        pub fn serialize(&self) -> Vec<u8> {
140            let bytes = crate::serialize_header(crate::MAGIC_BYTES, crate::PCZT_VERSION_1);
141            postcard::to_extend(&self, bytes).expect("can serialize into memory")
142        }
143    }
144
145    /// Encodes the in-memory [`super::Pczt`] into the v1 serialization type [`Pczt`].
146    impl TryFrom<super::Pczt> for Pczt {
147        type Error = super::EncodingError;
148
149        fn try_from(pczt: super::Pczt) -> Result<Self, Self::Error> {
150            // The v1 encoding cannot represent V6 or V7 transactions.
151            if match pczt.global.tx_version {
152                zcash_protocol::constants::V6_TX_VERSION => true,
153                #[cfg(zcash_unstable = "nutachyon")]
154                zcash_protocol::constants::V7_TX_VERSION => true,
155                _ => false,
156            } {
157                return Err(super::EncodingError::UnsupportedTxVersion);
158            }
159
160            // The v1 format cannot represent an Ironwood bundle in any state other
161            // than the canonical empty one; a parser of the v1 encoding will
162            // reconstruct exactly that value.
163            if pczt.ironwood != orchard::EMPTY_IRONWOOD {
164                return Err(super::EncodingError::UnsupportedTxVersion);
165            }
166
167            Ok(Self {
168                global: pczt.global,
169                transparent: pczt.transparent,
170                sapling: sapling::v1::Bundle::try_from(pczt.sapling)?,
171                orchard: orchard::v1::Bundle::try_from(pczt.orchard)?,
172            })
173        }
174    }
175
176    impl From<Pczt> for super::Pczt {
177        fn from(pczt: Pczt) -> Self {
178            Self {
179                global: pczt.global,
180                transparent: pczt.transparent,
181                sapling: pczt.sapling.into(),
182                orchard: pczt.orchard.into(),
183                ironwood: orchard::EMPTY_IRONWOOD,
184            }
185        }
186    }
187
188    #[cfg(test)]
189    mod tests {
190        use zcash_protocol::consensus::BranchId;
191
192        use crate::roles::creator::Creator;
193
194        #[test]
195        fn v1_refuses_v6_pczts_and_non_canonical_ironwood_bundles() {
196            // A v6 tx cannot be encoded as a v1 PCZT, even when its Ironwood bundle is
197            // canonically empty.
198            let pczt = Creator::new(
199                BranchId::Nu6_3.into(),
200                10_000_000,
201                133,
202                Some([0; 32]),
203                Some([0; 32]),
204            )
205            .unwrap()
206            .build()
207            .unwrap();
208            assert!(matches!(
209                super::Pczt::try_from(pczt),
210                Err(crate::EncodingError::UnsupportedTxVersion)
211            ));
212
213            // A v5 tx carrying non-canonical Ironwood bundle data cannot be encoded
214            // as a v1 PCZT, because the data would be dropped.
215            let mut pczt = Creator::new(
216                BranchId::Nu6.into(),
217                10_000_000,
218                133,
219                Some([0; 32]),
220                Some([0; 32]),
221            )
222            .unwrap()
223            .build()
224            .unwrap();
225            pczt.ironwood.bsk = Some(crate::common::SecretKeyBytes::new([1; 32]));
226            assert!(matches!(
227                super::Pczt::try_from(pczt),
228                Err(crate::EncodingError::UnsupportedTxVersion)
229            ));
230        }
231    }
232}
233
234/// Types and operations for the v2 Pczt encoding.
235pub mod v2 {
236    use alloc::vec::Vec;
237    use serde::{Deserialize, Serialize};
238
239    use crate::{common, orchard, sapling, transparent};
240
241    /// The in-memory type used for derived serialization of the v2 Pczt encoding.
242    #[derive(Clone, Debug, Serialize, Deserialize)]
243    pub struct Pczt {
244        global: common::Global,
245        // This value is set to `None` if the transparent bundle is empty,
246        // meaning inputs and outputs are empty.
247        transparent: Option<transparent::Bundle>,
248        // This value is set to `None` if the Sapling bundle is empty,
249        // meaning every field has its empty/default value.
250        sapling: Option<sapling::Bundle>,
251        // This value is set to `None` if the Orchard bundle is empty,
252        // meaning actions, value sum, anchor, zkproof, and bsk are all
253        // empty. Flags and note version are not checked, as values can be
254        // defaulted there.
255        orchard: Option<orchard::v2::Bundle>,
256        ironwood: Option<orchard::v2::Bundle>,
257    }
258
259    impl Pczt {
260        pub fn serialize(&self) -> Vec<u8> {
261            let bytes = crate::serialize_header(crate::MAGIC_BYTES, crate::PCZT_VERSION_2);
262            postcard::to_extend(&self, bytes).expect("can serialize into memory")
263        }
264    }
265
266    /// Encodes the in-memory [`super::Pczt`] into the v2 serialization type [`Pczt`],
267    /// omitting empty Transparent, Sapling, and Orchard bundles.
268    impl TryFrom<super::Pczt> for Pczt {
269        type Error = super::EncodingError;
270
271        fn try_from(pczt: super::Pczt) -> Result<Self, Self::Error> {
272            Ok(Self {
273                global: pczt.global,
274                transparent: (pczt.transparent != transparent::EMPTY_BUNDLE)
275                    .then_some(pczt.transparent),
276                sapling: sapling::v2::encode(pczt.sapling),
277                orchard: orchard::v2::encode(pczt.orchard, &orchard::EMPTY_ORCHARD)?,
278                ironwood: orchard::v2::encode(pczt.ironwood, &orchard::EMPTY_IRONWOOD)?,
279            })
280        }
281    }
282
283    impl Pczt {
284        pub(super) fn into_logical(self) -> Result<super::Pczt, super::ParseError> {
285            Ok(super::Pczt {
286                global: self.global,
287                transparent: self.transparent.unwrap_or(transparent::EMPTY_BUNDLE),
288                sapling: self.sapling.unwrap_or(sapling::EMPTY_BUNDLE),
289                orchard: self
290                    .orchard
291                    .map(orchard::v2::Bundle::into_logical)
292                    .transpose()?
293                    .unwrap_or(orchard::EMPTY_ORCHARD),
294                ironwood: self
295                    .ironwood
296                    .map(orchard::v2::Bundle::into_logical)
297                    .transpose()?
298                    .unwrap_or(orchard::EMPTY_IRONWOOD),
299            })
300        }
301    }
302
303    #[cfg(test)]
304    mod tests {
305        use zcash_protocol::consensus::BranchId;
306
307        use super::Pczt;
308        use crate::{orchard::NoteVersion, roles::creator::Creator};
309
310        #[test]
311        fn empty_bundles_encode_as_none_and_decode_as_empty() {
312            // Absent anchors: the shielded bundles carry no anchor and no
313            // spends/actions, so they are fully empty and omitted.
314            let pczt = Creator::new(BranchId::Nu6_3.into(), 10_000_000, 133, None, None)
315                .unwrap()
316                .build()
317                .unwrap();
318
319            let encoded = Pczt::try_from(pczt).unwrap();
320
321            assert!(encoded.transparent.is_none());
322            assert!(encoded.sapling.is_none());
323            assert!(encoded.orchard.is_none());
324            assert!(encoded.ironwood.is_none());
325
326            let decoded = crate::parse(&encoded.serialize()).unwrap();
327
328            assert!(decoded.transparent.inputs.is_empty());
329            assert!(decoded.transparent.outputs.is_empty());
330            assert!(decoded.sapling.spends.is_empty());
331            assert!(decoded.sapling.outputs.is_empty());
332            assert!(decoded.sapling.anchor.is_none());
333            assert!(decoded.orchard.actions.is_empty());
334            assert_eq!(decoded.orchard.note_version, NoteVersion::V2);
335            {
336                assert!(decoded.ironwood.actions.is_empty());
337                assert_eq!(decoded.ironwood.note_version, NoteVersion::V3);
338            }
339        }
340
341        #[test]
342        fn anchored_bundles_are_preserved() {
343            // A Sapling/Orchard bundle with a non-empty anchor differs from its
344            // empty form, so it must not be omitted even with no spends/actions,
345            // and the anchor must survive the v2 round-trip.
346            let pczt = Creator::new(
347                BranchId::Nu6.into(),
348                10_000_000,
349                133,
350                Some([1; 32]),
351                Some([2; 32]),
352            )
353            .unwrap()
354            .build()
355            .unwrap();
356
357            let encoded = Pczt::try_from(pczt).unwrap();
358
359            assert!(encoded.transparent.is_none());
360            assert!(encoded.sapling.is_some());
361            assert!(encoded.orchard.is_some());
362
363            let decoded = crate::parse(&encoded.serialize()).unwrap();
364
365            assert_eq!(decoded.sapling.anchor, Some([1; 32]));
366            assert_eq!(decoded.orchard.anchor, Some([2; 32]));
367        }
368
369        #[test]
370        fn non_canonical_orchard_flags_and_note_version_prevent_omission() {
371            let mut pczt = Creator::new(
372                BranchId::Nu6.into(),
373                10_000_000,
374                133,
375                Some([0; 32]),
376                Some([0; 32]),
377            )
378            .unwrap()
379            .build()
380            .unwrap();
381            pczt.orchard.flags = 0;
382            pczt.orchard.note_version = NoteVersion::V3;
383
384            // A bundle whose flags or note version differ from the canonical empty
385            // bundle is not omitted, so that those fields round-trip losslessly.
386            let encoded = Pczt::try_from(pczt.clone()).unwrap();
387            assert!(encoded.orchard.is_some());
388
389            let decoded = encoded.into_logical().unwrap();
390            assert_eq!(decoded.orchard, pczt.orchard);
391            assert_eq!(decoded.orchard.flags, 0);
392            assert_eq!(decoded.orchard.note_version, NoteVersion::V3);
393        }
394    }
395}
396
397/// Errors that can occur while serializing a PCZT.
398#[derive(Debug)]
399#[non_exhaustive]
400pub enum EncodingError {
401    /// The requested transaction version cannot be represented in this PCZT
402    /// encoding.
403    UnsupportedTxVersion,
404    /// The v1 PCZT encoding does not support this Orchard note plaintext version.
405    UnsupportedOrchardNoteVersion,
406    /// The PCZT contains data that can only be represented in v2.
407    RequiresV2,
408}
409
410/// A version of the PCZT serialization format.
411#[derive(Clone, Copy, Debug, PartialEq, Eq)]
412pub enum EncodingVersion {
413    /// The version 1 encoding, produced by [`v1::Pczt::serialize`].
414    V1,
415    /// The version 2 encoding, produced by [`v2::Pczt::serialize`].
416    V2,
417}
418
419impl Pczt {
420    /// Whether this PCZT carries any inputs or outputs in the given pool.
421    ///
422    /// Every bundle is always present as a value, so its existence says nothing; this asks whether
423    /// it holds anything.
424    pub fn has_data_in_pool(&self, pool: PoolType) -> bool {
425        match pool {
426            PoolType::TRANSPARENT => {
427                !self.transparent.inputs().is_empty() || !self.transparent.outputs().is_empty()
428            }
429            PoolType::SAPLING => {
430                !self.sapling.spends().is_empty() || !self.sapling.outputs().is_empty()
431            }
432            PoolType::ORCHARD => !self.orchard.actions().is_empty(),
433            PoolType::IRONWOOD => !self.ironwood.actions().is_empty(),
434        }
435    }
436
437    /// Parses a PCZT from its encoding.
438    pub fn parse(bytes: &[u8]) -> Result<Self, ParseError> {
439        let (version, body) = parse_header(bytes, MAGIC_BYTES).map_err(|e| match e {
440            HeaderParseError::InvalidMagic => ParseError::NotPczt,
441            HeaderParseError::TooShort => ParseError::TooShort,
442        })?;
443        match version {
444            PCZT_VERSION_1 => postcard::from_bytes::<v1::Pczt>(body)
445                .map(Pczt::from)
446                .map_err(ParseError::Invalid),
447            PCZT_VERSION_2 => postcard::from_bytes::<v2::Pczt>(body)
448                .map_err(ParseError::Invalid)
449                .and_then(v2::Pczt::into_logical),
450            _ => Err(ParseError::UnknownVersion(version)),
451        }
452    }
453
454    /// Serializes this PCZT in the minimal encoding version capable of
455    /// representing its content: the v1 encoding whenever the PCZT is
456    /// representable in it (maximizing compatibility with receivers that
457    /// predate the v2 encoding), and the v2 encoding otherwise.
458    ///
459    /// To force a specific PCZT version, use [`v1::Pczt`] or [`v2::Pczt`]
460    /// directly. To learn the encoding version that was used, use
461    /// [`Pczt::serialize_with_version`].
462    pub fn serialize(self) -> Result<Vec<u8>, EncodingError> {
463        self.serialize_with_version().map(|(_, bytes)| bytes)
464    }
465
466    /// Serializes this PCZT, reporting the encoding version it was serialized
467    /// in alongside the encoded bytes.
468    ///
469    /// The encoding version is selected as documented for [`Pczt::serialize`],
470    /// which returns the same bytes.
471    ///
472    /// # Errors
473    ///
474    /// Returns an [`EncodingError`] if this PCZT's content cannot be
475    /// represented in any encoding version.
476    pub fn serialize_with_version(self) -> Result<(EncodingVersion, Vec<u8>), EncodingError> {
477        // Fast pre-checks for the conditions that most commonly rule out the
478        // v1 encoding, avoiding the speculative clone below.
479        let requires_v2 = match self.global.tx_version {
480            zcash_protocol::constants::V6_TX_VERSION => true,
481            #[cfg(zcash_unstable = "nutachyon")]
482            zcash_protocol::constants::V7_TX_VERSION => true,
483            _ => false,
484        };
485        if !requires_v2 && self.ironwood == orchard::EMPTY_IRONWOOD {
486            // The full v1-representability conditions live in the bundle
487            // conversions; attempting the conversion is the single source of
488            // truth for them.
489            if let Ok(v1) = v1::Pczt::try_from(self.clone()) {
490                return Ok((EncodingVersion::V1, v1.serialize()));
491            }
492        }
493        Ok((EncodingVersion::V2, v2::Pczt::try_from(self)?.serialize()))
494    }
495
496    /// Resolves derived or compact field representations carried by this PCZT.
497    ///
498    /// For improved efficiency, callers that will pass the same PCZT through
499    /// multiple roles should call this once up front. Parsing also resolves fields
500    /// defensively.
501    #[cfg(feature = "orchard")]
502    pub fn resolve_fields(&mut self) -> Result<(), ::orchard::pczt::ParseError> {
503        self.orchard.resolve_fields()?;
504        self.ironwood.resolve_fields()
505    }
506
507    /// Parses this PCZT's bundles and constructs a `TransactionData` using caller-provided
508    /// bundle extraction closures.
509    ///
510    /// This handles bundle parsing, version validation, consensus branch ID parsing,
511    /// lock time computation, and final assembly, delegating bundle extraction to the
512    /// caller via closures that receive references to the parsed bundles.
513    #[cfg(any(feature = "io-finalizer", feature = "signer", feature = "tx-extractor"))]
514    pub(crate) fn extract_tx_data<A, E>(
515        self,
516        anchor_requirement: common::AnchorRequirement,
517        extract_transparent: impl FnOnce(
518            &::transparent::pczt::Bundle,
519        ) -> Result<
520            Option<::transparent::bundle::Bundle<A::TransparentAuth>>,
521            E,
522        >,
523        extract_sapling: impl FnOnce(
524            &::sapling::pczt::Bundle,
525        ) -> Result<
526            Option<::sapling::Bundle<A::SaplingAuth, zcash_protocol::value::ZatBalance>>,
527            E,
528        >,
529        extract_orchard: impl FnOnce(
530            &::orchard::pczt::Bundle,
531        ) -> Result<
532            Option<::orchard::Bundle<A::OrchardAuth, zcash_protocol::value::ZatBalance>>,
533            E,
534        >,
535        extract_ironwood: impl FnOnce(
536            &::orchard::pczt::Bundle,
537        ) -> Result<
538            Option<::orchard::Bundle<A::OrchardAuth, zcash_protocol::value::ZatBalance>>,
539            E,
540        >,
541    ) -> Result<ParsedPczt<A>, E>
542    where
543        A: Authorization,
544        E: From<ExtractError>,
545    {
546        let Pczt {
547            global,
548            transparent,
549            sapling,
550            orchard,
551            ironwood,
552        } = self;
553
554        let consensus_branch_id = BranchId::try_from(global.consensus_branch_id)
555            .map_err(|_| ExtractError::UnknownConsensusBranchId)?;
556        let orchard_protocol_revision = consensus_branch_id
557            .orchard_protocol_revision()
558            // The supported transaction formats do not exist prior to NU5, so no
559            // transaction could be extracted under such a branch in any case.
560            .ok_or(ExtractError::UnsupportedConsensusBranchId)?;
561
562        let version = match (global.tx_version, global.version_group_id) {
563            (V5_TX_VERSION, V5_VERSION_GROUP_ID) => Ok(TxVersion::V5),
564            (V6_TX_VERSION, V6_VERSION_GROUP_ID) => Ok(TxVersion::V6),
565            #[cfg(zcash_unstable = "nutachyon")]
566            (V7_TX_VERSION, V7_VERSION_GROUP_ID) => Ok(TxVersion::V7),
567            (version, version_group_id) => Err(ExtractError::UnsupportedTxVersion {
568                version,
569                version_group_id,
570            }),
571        }?;
572
573        match version {
574            // Only V6 and later transaction formats carry an Ironwood bundle.
575            TxVersion::Sprout(_) | TxVersion::V3 | TxVersion::V4 | TxVersion::V5 => {
576                if ironwood != crate::orchard::EMPTY_IRONWOOD {
577                    return Err(ExtractError::IronwoodNotSupported.into());
578                }
579            }
580            // V6 and later transaction formats do not exist prior to NU6.3 (the first
581            // upgrade under which the Orchard protocol is at revision V3).
582            TxVersion::V6 => {
583                if orchard_protocol_revision < OrchardProtocolRevision::V3 {
584                    return Err(ExtractError::UnsupportedConsensusBranchId.into());
585                }
586            }
587            #[cfg(zcash_unstable = "nutachyon")]
588            TxVersion::V7 => {
589                if orchard_protocol_revision < OrchardProtocolRevision::V3 {
590                    return Err(ExtractError::UnsupportedConsensusBranchId.into());
591                }
592            }
593        }
594
595        let transparent = transparent
596            .into_parsed()
597            .map_err(ExtractError::TransparentParse)?;
598        let sapling = sapling
599            .into_parsed(anchor_requirement)
600            .map_err(ExtractError::SaplingParse)?;
601        let orchard_bundle_version = crate::orchard::bundle_version_for_revision(
602            orchard_protocol_revision,
603            ::orchard::ValuePool::Orchard,
604        )
605        .expect("the Orchard pool is supported under every protocol revision");
606        let orchard = orchard
607            .into_parsed_with_version(orchard_bundle_version, anchor_requirement)
608            .map_err(ExtractError::OrchardParse)?;
609        let ironwood = ironwood
610            .into_ironwood_parsed(anchor_requirement)
611            .map_err(ExtractError::IronwoodParse)?;
612
613        let lock_time = determine_lock_time(&global, transparent.inputs())
614            .ok_or(ExtractError::IncompatibleLockTimes)?;
615
616        let transparent_bundle = extract_transparent(&transparent)?;
617        let sapling_bundle = extract_sapling(&sapling.bundle)?;
618        let orchard_bundle = extract_orchard(&orchard.bundle)?;
619        let ironwood_bundle = extract_ironwood(&ironwood.bundle)?;
620
621        let tx_data = match version {
622            TxVersion::V6 => TransactionData::from_parts_v6(
623                consensus_branch_id,
624                lock_time,
625                global.expiry_height.into(),
626                transparent_bundle,
627                sapling_bundle,
628                orchard_bundle,
629                ironwood_bundle,
630            ),
631            #[cfg(zcash_unstable = "nutachyon")]
632            TxVersion::V7 => TransactionData::from_parts_v7(
633                consensus_branch_id,
634                lock_time,
635                global.expiry_height.into(),
636                transparent_bundle,
637                sapling_bundle,
638                orchard_bundle,
639                ironwood_bundle,
640            ),
641            _ => TransactionData::from_parts(
642                version,
643                consensus_branch_id,
644                lock_time,
645                global.expiry_height.into(),
646                transparent_bundle,
647                None,
648                sapling_bundle,
649                orchard_bundle,
650            ),
651        };
652
653        Ok(ParsedPczt {
654            global,
655            transparent,
656            sapling,
657            orchard,
658            ironwood,
659            tx_data,
660        })
661    }
662
663    /// Gets the effects of this transaction.
664    #[cfg(any(feature = "io-finalizer", feature = "signer"))]
665    pub fn into_effects(self) -> Result<TransactionData<EffectsOnly>, ExtractError> {
666        let anchor_requirement =
667            common::AnchorRequirement::for_pre_authorization(self.global.tx_version);
668
669        self.extract_tx_data(
670            anchor_requirement,
671            |t| {
672                t.extract_effects()
673                    .map_err(ExtractError::TransparentExtract)
674            },
675            |s| s.extract_effects().map_err(ExtractError::SaplingExtract),
676            |o| o.extract_effects().map_err(ExtractError::OrchardExtract),
677            |i| i.extract_effects().map_err(ExtractError::IronwoodExtract),
678        )
679        .map(|parsed| parsed.tx_data)
680    }
681}
682
683/// The result of parsing a PCZT and constructing its `TransactionData`.
684#[cfg(any(feature = "io-finalizer", feature = "signer", feature = "tx-extractor"))]
685#[cfg_attr(
686    not(any(feature = "io-finalizer", feature = "signer")),
687    allow(dead_code)
688)]
689pub(crate) struct ParsedPczt<A: Authorization> {
690    pub(crate) global: Global,
691    pub(crate) transparent: ::transparent::pczt::Bundle,
692    pub(crate) sapling: crate::sapling::Parsed,
693    pub(crate) orchard: crate::orchard::Parsed,
694    pub(crate) ironwood: crate::orchard::Parsed,
695    pub(crate) tx_data: TransactionData<A>,
696}
697
698#[cfg(any(feature = "io-finalizer", feature = "signer"))]
699pub struct EffectsOnly;
700
701#[cfg(any(feature = "io-finalizer", feature = "signer"))]
702impl Authorization for EffectsOnly {
703    type TransparentAuth = ::transparent::bundle::EffectsOnly;
704    type SaplingAuth = ::sapling::bundle::EffectsOnly;
705    type OrchardAuth = ::orchard::bundle::EffectsOnly;
706}
707
708/// Computes the digests that the signature hashes of `tx_data` commit to.
709///
710/// `global` must be the global data of the PCZT that `tx_data` was extracted from.
711#[cfg(any(feature = "io-finalizer", feature = "signer"))]
712pub(crate) fn precompute_sighash_data(
713    global: &Global,
714    tx_data: TransactionData<EffectsOnly>,
715) -> Result<PrecomputedSighashData<EffectsOnly>, ExtractError> {
716    PrecomputedSighashData::new_zip244(tx_data).map_err(|_| ExtractError::UnsupportedTxVersion {
717        version: global.tx_version,
718        version_group_id: global.version_group_id,
719    })
720}
721
722/// Computes the signature hash of an input to the transaction extracted from a PCZT.
723#[cfg(any(feature = "io-finalizer", feature = "signer"))]
724pub(crate) fn sighash(
725    sighash_data: &PrecomputedSighashData<EffectsOnly>,
726    signable_input: &SignableInput,
727) -> [u8; 32] {
728    *sighash_data.signature_hash(signable_input).as_ref()
729}
730
731/// Errors that can occur while parsing PCZT bundles and extracting transaction data.
732#[cfg(any(feature = "io-finalizer", feature = "signer", feature = "tx-extractor"))]
733#[derive(Debug)]
734#[non_exhaustive]
735pub enum ExtractError {
736    /// The PCZT's transparent inputs have incompatible lock time requirements.
737    IncompatibleLockTimes,
738    /// An error occurred extracting the Ironwood protocol bundle from the Ironwood PCZT bundle.
739    IronwoodExtract(::orchard::pczt::TxExtractorError),
740    /// The PCZT carries Ironwood bundle data, but its transaction version does not
741    /// support an Ironwood bundle.
742    IronwoodNotSupported,
743    /// An error occurred parsing the Ironwood PCZT bundle from the PCZT data.
744    IronwoodParse(crate::orchard::ParseError),
745    /// An error occurred extracting the Orchard protocol bundle from the Orchard PCZT bundle.
746    OrchardExtract(::orchard::pczt::TxExtractorError),
747    /// An error occurred parsing the Orchard PCZT bundle from the PCZT data.
748    OrchardParse(crate::orchard::ParseError),
749    /// An error occurred extracting the Sapling protocol bundle from the Sapling PCZT bundle.
750    SaplingExtract(::sapling::pczt::TxExtractorError),
751    /// An error occurred parsing the Sapling PCZT bundle from the PCZT data.
752    SaplingParse(crate::sapling::ParseError),
753    /// An error occurred extracting the transparent protocol bundle from the
754    /// transparent PCZT bundle.
755    TransparentExtract(::transparent::pczt::TxExtractorError),
756    /// An error occurred parsing the transparent PCZT bundle from the PCZT data.
757    TransparentParse(::transparent::pczt::ParseError),
758    /// The consensus branch ID requested by the PCZT does not correspond to a
759    /// known network upgrade.
760    UnknownConsensusBranchId,
761    /// The network upgrade for the PCZT's consensus branch ID predates the v5
762    /// transaction format, so no transaction can be extracted from it.
763    UnsupportedConsensusBranchId,
764    /// The PCZT specifies an unsupported transaction version.
765    UnsupportedTxVersion { version: u32, version_group_id: u32 },
766}
767
768#[cfg(any(feature = "io-finalizer", feature = "signer", feature = "tx-extractor"))]
769impl core::fmt::Display for ExtractError {
770    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
771        match self {
772            ExtractError::IncompatibleLockTimes => {
773                write!(f, "the transparent inputs have incompatible lock times")
774            }
775            ExtractError::IronwoodExtract(e) => {
776                write!(f, "could not extract the Ironwood bundle: {e}")
777            }
778            ExtractError::IronwoodNotSupported => write!(
779                f,
780                "the PCZT carries Ironwood bundle data, but its transaction version has no \
781                 Ironwood bundle"
782            ),
783            ExtractError::IronwoodParse(e) => {
784                write!(f, "could not parse the Ironwood bundle: {e:?}")
785            }
786            ExtractError::OrchardExtract(e) => {
787                write!(f, "could not extract the Orchard bundle: {e}")
788            }
789            ExtractError::OrchardParse(e) => write!(f, "could not parse the Orchard bundle: {e:?}"),
790            ExtractError::SaplingExtract(e) => {
791                write!(f, "could not extract the Sapling bundle: {e}")
792            }
793            ExtractError::SaplingParse(e) => write!(f, "could not parse the Sapling bundle: {e:?}"),
794            ExtractError::TransparentExtract(e) => {
795                write!(f, "could not extract the transparent bundle: {e:?}")
796            }
797            ExtractError::TransparentParse(e) => {
798                write!(f, "could not parse the transparent bundle: {e:?}")
799            }
800            ExtractError::UnknownConsensusBranchId => write!(f, "unknown consensus branch ID"),
801            ExtractError::UnsupportedConsensusBranchId => write!(
802                f,
803                "the consensus branch ID predates the v5 transaction format"
804            ),
805            ExtractError::UnsupportedTxVersion {
806                version,
807                version_group_id,
808            } => write!(
809                f,
810                "unsupported transaction version {version} (version group ID {version_group_id})"
811            ),
812        }
813    }
814}
815
816#[cfg(any(feature = "io-finalizer", feature = "signer", feature = "tx-extractor"))]
817impl core::error::Error for ExtractError {}
818
819/// Errors that can occur while parsing a PCZT.
820#[derive(Debug)]
821pub enum ParseError {
822    /// The bytes do not contain a PCZT.
823    NotPczt,
824    /// The PCZT encoding was invalid.
825    Invalid(postcard::Error),
826    /// The PCZT encoding omitted a field that is required by the logical PCZT
827    /// type.
828    MissingRequiredField(&'static str),
829    /// The bytes are too short to contain a PCZT.
830    TooShort,
831    /// The PCZT has an unknown version.
832    UnknownVersion(u32),
833}
834
835impl core::fmt::Display for ParseError {
836    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
837        match self {
838            ParseError::NotPczt => write!(f, "the bytes do not contain a PCZT"),
839            ParseError::Invalid(e) => write!(f, "invalid PCZT encoding: {e}"),
840            ParseError::MissingRequiredField(field) => {
841                write!(f, "the PCZT encoding omitted the required field {field}")
842            }
843            ParseError::TooShort => write!(f, "the bytes are too short to contain a PCZT"),
844            ParseError::UnknownVersion(v) => write!(f, "unknown PCZT version {v}"),
845        }
846    }
847}
848
849impl core::error::Error for ParseError {}
850
851#[cfg(all(test, any(feature = "io-finalizer", feature = "signer")))]
852mod extraction_tests {
853    use zcash_primitives::transaction::{
854        TxVersion,
855        sighash::{PrecomputedSighashData, SignableInput},
856        txid::{TxIdDigester, to_txid},
857    };
858    use zcash_protocol::consensus::BranchId;
859
860    use crate::{ExtractError, roles::creator::Creator};
861
862    #[test]
863    fn nu7_pczt_preserves_canonical_v6_digests() {
864        /// Match the independent empty NU7 v6 fixture in the transaction codec tests.
865        const EXPIRY_HEIGHT: u32 = 1;
866        /// Mainnet's registered coin type; no keys or addresses are present in this fixture.
867        const COIN_TYPE: u32 = 133;
868        /// Independently calculated with Python hashlib.blake2b using ZIP 244/229 domains.
869        const EXPECTED_DIGEST: &str =
870            "78296c68a370c2e1f058d997c81c7b011c4562c52fc5ca2994ad59cd179fbe9e";
871        let pczt = Creator::new(BranchId::Nu7.into(), EXPIRY_HEIGHT, COIN_TYPE, None, None)
872            .unwrap()
873            .build()
874            .unwrap();
875        let data = pczt.into_effects().unwrap();
876        assert_eq!(data.version(), TxVersion::V6);
877        let digests = data.digest(TxIdDigester);
878        let expected = hex::decode(EXPECTED_DIGEST).unwrap();
879        assert_eq!(
880            to_txid(data.version(), data.consensus_branch_id(), &digests)
881                .expect("Creator::new returns a compatible version")
882                .as_ref()
883                .as_slice(),
884            expected
885        );
886        assert_eq!(
887            PrecomputedSighashData::new_zip244(data)
888                .unwrap()
889                .signature_hash(&SignableInput::Shielded)
890                .as_ref()
891                .as_slice(),
892            expected
893        );
894    }
895
896    #[test]
897    fn v5_pczt_with_ironwood_data_does_not_extract() {
898        let mut pczt = Creator::new(
899            BranchId::Nu6.into(),
900            10_000_000,
901            133,
902            Some([0; 32]),
903            Some([0; 32]),
904        )
905        .unwrap()
906        .build()
907        .unwrap();
908        pczt.ironwood.bsk = Some(crate::common::SecretKeyBytes::new([1; 32]));
909        assert!(matches!(
910            pczt.into_effects(),
911            Err(ExtractError::IronwoodNotSupported)
912        ));
913    }
914
915    #[test]
916    fn v6_pczt_with_pre_nu6_3_branch_does_not_extract() {
917        let mut pczt = Creator::new(
918            BranchId::Nu6_3.into(),
919            10_000_000,
920            133,
921            Some([0; 32]),
922            Some([0; 32]),
923        )
924        .unwrap()
925        .build()
926        .unwrap();
927        pczt.global.consensus_branch_id = BranchId::Nu6_2.into();
928        assert!(matches!(
929            pczt.into_effects(),
930            Err(ExtractError::UnsupportedConsensusBranchId)
931        ));
932    }
933
934    #[test]
935    #[cfg(zcash_unstable = "nutachyon")]
936    fn nu_tachyon_pczt_extracts_as_v7() {
937        let pczt = Creator::new(
938            BranchId::NuTachyon.into(),
939            10_000_000,
940            133,
941            Some([0; 32]),
942            Some([0; 32]),
943        )
944        .unwrap()
945        .build()
946        .unwrap();
947
948        let tx_data = pczt.into_effects().unwrap();
949        assert_eq!(tx_data.version(), TxVersion::V7);
950        assert_eq!(tx_data.consensus_branch_id(), BranchId::NuTachyon);
951    }
952}
953
954#[cfg(test)]
955mod serialize_tests {
956    use zcash_protocol::consensus::BranchId;
957
958    use crate::{EncodingVersion, roles::creator::Creator};
959
960    fn encoding_version(bytes: &[u8]) -> u32 {
961        assert_eq!(&bytes[..4], crate::MAGIC_BYTES);
962        u32::from_le_bytes(bytes[4..8].try_into().unwrap())
963    }
964
965    #[test]
966    fn serialize_emits_minimal_encoding() {
967        // A v1-representable (v5, canonical-empty Ironwood) PCZT serializes as v1.
968        let pczt = Creator::new(
969            BranchId::Nu6.into(),
970            10_000_000,
971            133,
972            Some([0; 32]),
973            Some([0; 32]),
974        )
975        .unwrap()
976        .build()
977        .unwrap();
978        let bytes = pczt.clone().serialize().unwrap();
979        assert_eq!(encoding_version(&bytes), crate::PCZT_VERSION_1);
980        // The minimal encoding still round-trips through the ordinary parser.
981        assert!(crate::Pczt::parse(&bytes).is_ok());
982
983        // Non-canonical Ironwood data forces the v2 encoding.
984        let mut with_ironwood = pczt.clone();
985        with_ironwood.ironwood.bsk = Some(crate::common::SecretKeyBytes::new([1; 32]));
986        assert_eq!(
987            encoding_version(&with_ironwood.serialize().unwrap()),
988            crate::PCZT_VERSION_2,
989        );
990
991        // An Orchard note-plaintext version the v1 encoding cannot carry
992        // forces the v2 encoding.
993        let mut with_note_v3 = pczt;
994        with_note_v3.orchard.note_version = crate::orchard::NoteVersion::V3;
995        assert_eq!(
996            encoding_version(&with_note_v3.serialize().unwrap()),
997            crate::PCZT_VERSION_2,
998        );
999
1000        // A v6 transaction forces the v2 encoding.
1001        let v6 = Creator::new(
1002            BranchId::Nu6_3.into(),
1003            10_000_000,
1004            133,
1005            Some([0; 32]),
1006            Some([0; 32]),
1007        )
1008        .unwrap()
1009        .build()
1010        .unwrap();
1011        assert_eq!(
1012            encoding_version(&v6.serialize().unwrap()),
1013            crate::PCZT_VERSION_2,
1014        );
1015    }
1016
1017    #[test]
1018    fn serialize_with_version_reports_the_encoding_used() {
1019        // A v1-representable (v5, canonical-empty Ironwood) PCZT.
1020        let v5 = Creator::new(
1021            BranchId::Nu6.into(),
1022            10_000_000,
1023            133,
1024            Some([0; 32]),
1025            Some([0; 32]),
1026        )
1027        .unwrap()
1028        .build()
1029        .unwrap();
1030
1031        // A v6 transaction, which the v1 encoding cannot represent.
1032        let v6 = Creator::new(
1033            BranchId::Nu6_3.into(),
1034            10_000_000,
1035            133,
1036            Some([0; 32]),
1037            Some([0; 32]),
1038        )
1039        .unwrap()
1040        .build()
1041        .unwrap();
1042
1043        for (pczt, expected, expected_header) in [
1044            (v5, EncodingVersion::V1, crate::PCZT_VERSION_1),
1045            (v6, EncodingVersion::V2, crate::PCZT_VERSION_2),
1046        ] {
1047            let (version, bytes) = pczt.clone().serialize_with_version().unwrap();
1048
1049            // The reported version is the one the encoding's header carries.
1050            assert_eq!(version, expected);
1051            assert_eq!(encoding_version(&bytes), expected_header);
1052
1053            // `serialize` produces exactly these bytes.
1054            assert_eq!(pczt.serialize().unwrap(), bytes);
1055        }
1056    }
1057}