Skip to main content

zcash_client_backend/
decrypt.rs

1use std::collections::HashMap;
2
3use sapling::note_encryption::{PreparedIncomingViewingKey, SaplingDomain};
4use zcash_keys::keys::{OutgoingViewingKey, UnifiedFullViewingKey};
5use zcash_note_encryption::{try_note_decryption, try_output_recovery_with_ovk};
6use zcash_primitives::{
7    transaction::Transaction, transaction::components::sapling::zip212_enforcement,
8};
9use zcash_protocol::{
10    ShieldedPool,
11    consensus::{self, BlockHeight, NetworkUpgrade},
12    memo::MemoBytes,
13    value::Zatoshis,
14};
15use zip32::Scope;
16
17use crate::data_api::DecryptedTransaction;
18
19#[cfg(feature = "orchard")]
20use orchard::note_encryption::{
21    DomainVersion, IronwoodVersion, NoteEncryptionDomain, OrchardVersion,
22};
23
24/// An enumeration of the possible relationships a TXO can have to the wallet.
25#[derive(Debug, Copy, Clone, PartialEq, Eq)]
26pub enum TransferType {
27    /// The output was received on one of the wallet's external addresses via decryption using the
28    /// associated incoming viewing key, or at one of the wallet's transparent addresses.
29    Incoming,
30    /// The output is internal to a single wallet account, e.g. change: the recipient and the
31    /// funder are the same wallet account. For shielded outputs, this corresponds to decryption
32    /// using the account's internal incoming viewing key.
33    AccountInternal,
34    /// The output is internal to the wallet but spans accounts: a wallet account funded the
35    /// transaction and a different wallet account received the output. Only produced for
36    /// transparent outputs; shielded cross-account transfers are observed as separate `Outgoing`
37    /// (from the funder) and `Incoming` (to the recipient) outputs.
38    WalletInternal,
39    /// The output was decrypted using one of the wallet's outgoing viewing keys, or was created
40    /// in a transaction constructed by this wallet.
41    Outgoing,
42}
43
44/// A decrypted shielded output.
45pub struct DecryptedOutput<Note, AccountId> {
46    index: usize,
47    note: Note,
48    value_pool: ShieldedPool,
49    account: AccountId,
50    memo: MemoBytes,
51    transfer_type: TransferType,
52}
53
54impl<Note, AccountId> DecryptedOutput<Note, AccountId> {
55    pub fn new(
56        index: usize,
57        note: Note,
58        value_pool: ShieldedPool,
59        account: AccountId,
60        memo: MemoBytes,
61        transfer_type: TransferType,
62    ) -> Self {
63        Self {
64            index,
65            note,
66            value_pool,
67            account,
68            memo,
69            transfer_type,
70        }
71    }
72
73    /// The index of the output within the shielded outputs of the Sapling bundle or the actions of
74    /// the Orchard bundle, depending upon the type of [`Self::note`].
75    pub fn index(&self) -> usize {
76        self.index
77    }
78
79    /// The note within the output.
80    pub fn note(&self) -> &Note {
81        &self.note
82    }
83    /// Returns the value pool to which the note contributes its value.
84    pub fn value_pool(&self) -> ShieldedPool {
85        self.value_pool
86    }
87    /// The account that decrypted the note.
88    pub fn account(&self) -> &AccountId {
89        &self.account
90    }
91
92    /// The memo bytes included with the note.
93    pub fn memo(&self) -> &MemoBytes {
94        &self.memo
95    }
96
97    /// Returns a [`TransferType`] value that is determined based upon what type of key was used to
98    /// decrypt the transaction.
99    pub fn transfer_type(&self) -> TransferType {
100        self.transfer_type
101    }
102}
103
104impl<A> DecryptedOutput<sapling::Note, A> {
105    pub fn note_value(&self) -> Zatoshis {
106        Zatoshis::from_u64(self.note.value().inner())
107            .expect("Sapling note value is expected to have been validated by consensus.")
108    }
109}
110
111#[cfg(feature = "orchard")]
112impl<A> DecryptedOutput<orchard::note::Note, A> {
113    pub fn note_value(&self) -> Zatoshis {
114        Zatoshis::from_u64(self.note.value().inner())
115            .expect("Orchard note value is expected to have been validated by consensus.")
116    }
117}
118
119/// Returns every outgoing viewing key that a [ZIP 316]-compliant sender may have used to encrypt
120/// the outgoing ciphertexts of a transaction funded from the account that `ufvk` belongs to.
121///
122/// A sender selects one `(external, internal)` OVK pair for the entire transaction, taken from the
123/// protocol that the spent funds came from, and uses that pair for every shielded output of the
124/// transaction irrespective of the pool each output lands in; an OVK is pool-agnostic input to the
125/// outgoing-ciphertext KDF. The pool that funded a transaction cannot be determined from the
126/// transaction alone, so recovery must try each key the account is able to produce: Orchard
127/// external and internal, Sapling external and internal, and the transparent-derived pair.
128///
129/// [ZIP 316]: https://zips.z.cash/zip-0316#usage-of-outgoing-viewing-keys
130fn candidate_ovks(ufvk: &UnifiedFullViewingKey) -> Vec<OutgoingViewingKey> {
131    // Candidates are ordered by the ZIP 316 preference order of the item that produced them, with
132    // the external scope before the internal scope, so that the most likely candidate is tried
133    // first; correctness does not depend upon the order.
134    let mut ovks = Vec::with_capacity(6);
135
136    #[cfg(feature = "orchard")]
137    if let Some(fvk) = ufvk.orchard() {
138        ovks.push(fvk.to_ovk(Scope::External).into());
139        ovks.push(fvk.to_ovk(Scope::Internal).into());
140    }
141
142    if let Some(dfvk) = ufvk.sapling() {
143        ovks.push(dfvk.to_ovk(Scope::External).into());
144        ovks.push(dfvk.to_ovk(Scope::Internal).into());
145    }
146
147    #[cfg(feature = "transparent-inputs")]
148    if let Some(pubkey) = ufvk.p2pkh() {
149        let shielding_ovks = pubkey.ovks_for_shielding();
150        ovks.push(shielding_ovks.external().as_bytes().into());
151        ovks.push(shielding_ovks.internal().as_bytes().into());
152    }
153
154    ovks
155}
156
157/// Scans a [`Transaction`] for any information that can be decrypted by the set of
158/// [`UnifiedFullViewingKey`]s.
159///
160/// # Parameters
161/// - `params`: The network parameters corresponding to the network the transaction
162///   was created for.
163/// - `mined_height`: The height at which the transaction was mined, or `None` for
164///   unmined transactions.
165/// - `chain_tip_height`: The current chain tip height, if known. This parameter
166///   will be unused if `mined_height.is_some()`.
167/// - `tx`: The transaction to decrypt.
168/// - `ufvks`: The [`UnifiedFullViewingKey`]s to use in trial decryption, keyed
169///   by the identifiers for the wallet accounts they correspond to.
170pub fn decrypt_transaction<'a, P: consensus::Parameters, AccountId: Copy>(
171    params: &P,
172    mined_height: Option<BlockHeight>,
173    chain_tip_height: Option<BlockHeight>,
174    tx: &'a Transaction,
175    ufvks: &HashMap<AccountId, UnifiedFullViewingKey>,
176) -> DecryptedTransaction<'a, Transaction, AccountId> {
177    let zip212_enforcement = zip212_enforcement(
178        params,
179        // Height is block height for mined transactions, and the "mempool height" (chain height + 1)
180        // for mempool transactions. We fall back to Sapling activation if we have no other
181        // information.
182        mined_height.unwrap_or_else(|| {
183            chain_tip_height
184                .map(|max_height| max_height + 1) // "mempool height"
185                .or_else(|| params.activation_height(NetworkUpgrade::Sapling))
186                // Fall back to the genesis block in regtest mode.
187                .unwrap_or_else(|| BlockHeight::from(0))
188        }),
189    );
190    let sapling_bundle = tx.sapling_bundle();
191    let sapling_outputs = sapling_bundle
192        .iter()
193        .flat_map(|bundle| {
194            ufvks.iter().flat_map(|(account, ufvk)| {
195                let account = *account;
196                let sapling_domain = SaplingDomain::new(zip212_enforcement);
197                // An account can only receive a Sapling output if its UFVK has a Sapling item,
198                // but it can fund one from any pool, so outgoing recovery is attempted for
199                // every account.
200                let ivks = ufvk.sapling().map(|dfvk| {
201                    (
202                        PreparedIncomingViewingKey::new(&dfvk.to_ivk(Scope::External)),
203                        PreparedIncomingViewingKey::new(&dfvk.to_ivk(Scope::Internal)),
204                    )
205                });
206                let ovks = candidate_ovks(ufvk)
207                    .into_iter()
208                    .map(::sapling::keys::OutgoingViewingKey::from)
209                    .collect::<Vec<_>>();
210
211                bundle
212                    .shielded_outputs()
213                    .iter()
214                    .enumerate()
215                    .flat_map(move |(index, output)| {
216                        ivks.as_ref()
217                            .and_then(|(ivk_external, ivk_internal)| {
218                                try_note_decryption(&sapling_domain, ivk_external, output)
219                                    .map(|ret| (ret, TransferType::Incoming))
220                                    .or_else(|| {
221                                        try_note_decryption(&sapling_domain, ivk_internal, output)
222                                            .map(|ret| (ret, TransferType::AccountInternal))
223                                    })
224                            })
225                            .or_else(|| {
226                                // Recovery under any candidate OVK yields `Outgoing`
227                                // unconditionally: the incoming and internal trial decryptions
228                                // above have already run and failed, so this account is not the
229                                // recipient of the output. Which candidate matched identifies
230                                // only the pool the sender spent from, not the recipient.
231                                ovks.iter()
232                                    .find_map(|ovk| {
233                                        try_output_recovery_with_ovk(
234                                            &sapling_domain,
235                                            ovk,
236                                            output,
237                                            output.cv(),
238                                            output.out_ciphertext(),
239                                        )
240                                    })
241                                    .map(|ret| (ret, TransferType::Outgoing))
242                            })
243                            .into_iter()
244                            .map(move |((note, _, memo), transfer_type)| {
245                                DecryptedOutput::new(
246                                    index,
247                                    note,
248                                    ShieldedPool::Sapling,
249                                    account,
250                                    MemoBytes::from_bytes(&memo).expect("correct length"),
251                                    transfer_type,
252                                )
253                            })
254                    })
255            })
256        })
257        .collect();
258
259    // Trial-decrypt an Orchard-family (Orchard or Ironwood) bundle. The two bundle kinds are
260    // protocol-equivalent for trial decryption: both are Orchard-shaped and use the account's
261    // Orchard viewing keys. They differ only in the note plaintext version their domain accepts
262    // (selected by `V`: version 2 for [`OrchardVersion`], version 3 for [`IronwoodVersion`]) and
263    // in the value pool their notes belong to. Decrypting a bundle under the other kind's domain
264    // would silently detect nothing.
265    #[cfg(feature = "orchard")]
266    fn decrypt_orchard_protocol_bundle<V: DomainVersion, AccountId: Copy>(
267        ufvks: &HashMap<AccountId, UnifiedFullViewingKey>,
268        bundle: &orchard::bundle::Bundle<
269            orchard::bundle::Authorized,
270            zcash_protocol::value::ZatBalance,
271        >,
272        pool: orchard::ValuePool,
273    ) -> impl Iterator<Item = DecryptedOutput<(orchard::Note, orchard::ValuePool), AccountId>> {
274        let shielded_pool = crate::wallet::shielded_pool_for_value_pool(pool);
275        ufvks.iter().flat_map(move |(account, ufvk)| {
276            let account = *account;
277            // An account can only receive an Orchard-family output if its UFVK has an Orchard
278            // item, but it can fund one from any pool, so outgoing recovery is attempted for
279            // every account.
280            let ivks = ufvk.orchard().map(|fvk| {
281                (
282                    orchard::keys::PreparedIncomingViewingKey::new(&fvk.to_ivk(Scope::External)),
283                    orchard::keys::PreparedIncomingViewingKey::new(&fvk.to_ivk(Scope::Internal)),
284                )
285            });
286            let ovks = candidate_ovks(ufvk)
287                .into_iter()
288                .map(orchard::keys::OutgoingViewingKey::from)
289                .collect::<Vec<_>>();
290
291            bundle
292                .actions()
293                .iter()
294                .enumerate()
295                .flat_map(move |(index, action)| {
296                    let domain = NoteEncryptionDomain::<V>::for_action(action);
297                    ivks.as_ref()
298                        .and_then(|(ivk_external, ivk_internal)| {
299                            try_note_decryption(&domain, ivk_external, action)
300                                .map(|ret| (ret, TransferType::Incoming))
301                                .or_else(|| {
302                                    try_note_decryption(&domain, ivk_internal, action)
303                                        .map(|ret| (ret, TransferType::AccountInternal))
304                                })
305                        })
306                        .or_else(|| {
307                            // Recovery under any candidate OVK yields `Outgoing`
308                            // unconditionally: the incoming and internal trial decryptions
309                            // above have already run and failed, so this account is not the
310                            // recipient of the output. Which candidate matched identifies only
311                            // the pool the sender spent from, not the recipient.
312                            ovks.iter()
313                                .find_map(|ovk| {
314                                    try_output_recovery_with_ovk(
315                                        &domain,
316                                        ovk,
317                                        action,
318                                        action.cv_net(),
319                                        &action.encrypted_note().out_ciphertext,
320                                    )
321                                })
322                                .map(|ret| (ret, TransferType::Outgoing))
323                        })
324                        .into_iter()
325                        .map(move |((note, _, memo), transfer_type)| {
326                            DecryptedOutput::new(
327                                index,
328                                (note, pool),
329                                shielded_pool,
330                                account,
331                                MemoBytes::from_bytes(&memo).expect("correct length"),
332                                transfer_type,
333                            )
334                        })
335                })
336        })
337    }
338
339    #[cfg(feature = "orchard")]
340    let orchard_outputs = tx
341        .orchard_bundle()
342        .iter()
343        .flat_map(|bundle| {
344            decrypt_orchard_protocol_bundle::<OrchardVersion, _>(
345                ufvks,
346                bundle,
347                orchard::ValuePool::Orchard,
348            )
349        })
350        .collect();
351
352    #[cfg(feature = "orchard")]
353    let ironwood_outputs = tx
354        .ironwood_bundle()
355        .iter()
356        .flat_map(|bundle| {
357            decrypt_orchard_protocol_bundle::<IronwoodVersion, _>(
358                ufvks,
359                bundle,
360                orchard::ValuePool::Ironwood,
361            )
362        })
363        .collect();
364
365    DecryptedTransaction::new(
366        mined_height,
367        tx,
368        sapling_outputs,
369        #[cfg(feature = "orchard")]
370        orchard_outputs,
371        #[cfg(feature = "orchard")]
372        ironwood_outputs,
373    )
374}
375
376#[cfg(test)]
377mod tests {
378    use std::collections::HashMap;
379
380    use rand_chacha::ChaChaRng;
381    use rand_core::{CryptoRng, SeedableRng};
382    use sapling::{
383        note_encryption::sapling_note_encryption,
384        util::generate_random_rseed,
385        value::{NoteValue, ValueCommitTrapdoor, ValueCommitment},
386    };
387    use zcash_keys::keys::{OutgoingViewingKey, UnifiedFullViewingKey, UnifiedSpendingKey};
388    use zcash_note_encryption::Domain;
389    use zcash_primitives::transaction::{
390        Authorized, Transaction, TransactionData, TxVersion,
391        components::sapling::zip212_enforcement,
392    };
393    use zcash_protocol::{
394        consensus::{BlockHeight, BranchId, Network},
395        memo::MemoBytes,
396        value::{ZatBalance, Zatoshis},
397    };
398    use zip32::{AccountId, Scope};
399
400    use super::{SaplingDomain, TransferType, decrypt_transaction};
401
402    /// A height at which ZIP 212 is fully enforced on the test network, so that the note
403    /// plaintexts built here and the domain `decrypt_transaction` reconstructs agree.
404    const MINED_HEIGHT: u32 = 2_000_000;
405
406    const VALUE: Zatoshis = Zatoshis::const_from_u64(100_000);
407
408    /// The account whose keys are used to attempt recovery.
409    fn wallet_ufvk() -> UnifiedFullViewingKey {
410        UnifiedSpendingKey::from_seed(&Network::TestNetwork, &[0xab; 32], AccountId::ZERO)
411            .expect("seed produces a valid unified spending key")
412            .to_unified_full_viewing_key()
413    }
414
415    /// A key belonging to someone else, used to produce recipient addresses that the wallet
416    /// cannot detect with any of its incoming viewing keys.
417    fn counterparty_ufvk() -> UnifiedFullViewingKey {
418        UnifiedSpendingKey::from_seed(&Network::TestNetwork, &[0xcd; 32], AccountId::ZERO)
419            .expect("seed produces a valid unified spending key")
420            .to_unified_full_viewing_key()
421    }
422
423    /// Every outgoing viewing key that a ZIP 316-compliant sender may select for a transaction
424    /// funded from the account that `ufvk` belongs to, labelled by the item and scope it is
425    /// derived from. Derived here directly from the UFVK's items, independently of the candidate
426    /// set that `decrypt_transaction` builds.
427    fn zip316_sender_ovks(ufvk: &UnifiedFullViewingKey) -> Vec<(&'static str, OutgoingViewingKey)> {
428        let mut ovks: Vec<(&'static str, OutgoingViewingKey)> = Vec::new();
429
430        #[cfg(feature = "orchard")]
431        {
432            let fvk = ufvk.orchard().expect("UFVK has an Orchard item");
433            ovks.push(("Orchard external", fvk.to_ovk(Scope::External).into()));
434            ovks.push(("Orchard internal", fvk.to_ovk(Scope::Internal).into()));
435        }
436
437        let dfvk = ufvk.sapling().expect("UFVK has a Sapling item");
438        ovks.push(("Sapling external", dfvk.to_ovk(Scope::External).into()));
439        ovks.push(("Sapling internal", dfvk.to_ovk(Scope::Internal).into()));
440
441        #[cfg(feature = "transparent-inputs")]
442        {
443            let tfvk = ufvk.p2pkh().expect("UFVK has a transparent item");
444            let shielding_ovks = tfvk.ovks_for_shielding();
445            // Both halves of the pair become raw bytes as soon as they are used, so pin each half
446            // against the accessor that names its scope: a key bound to the wrong scope here
447            // cannot pass by relabelling both ends consistently.
448            assert_eq!(
449                shielding_ovks.external().as_bytes(),
450                tfvk.external_ovk().as_bytes()
451            );
452            assert_eq!(
453                shielding_ovks.internal().as_bytes(),
454                tfvk.internal_ovk().as_bytes()
455            );
456            ovks.push((
457                "transparent external",
458                shielding_ovks.external().as_bytes().into(),
459            ));
460            ovks.push((
461                "transparent internal",
462                shielding_ovks.internal().as_bytes().into(),
463            ));
464        }
465
466        ovks
467    }
468
469    /// A 32-byte value that is not derivable from any wallet key.
470    fn foreign_ovk() -> OutgoingViewingKey {
471        OutgoingViewingKey::from([0x5a; 32])
472    }
473
474    /// Builds a v5 transaction whose sole Sapling output pays `recipient`, with the outgoing
475    /// ciphertext encrypted under `ovk`.
476    ///
477    /// The zero-knowledge proof and binding signature are dummies; neither participates in note
478    /// decryption or outgoing-ciphertext recovery.
479    fn sapling_tx_paying(
480        recipient: ::sapling::PaymentAddress,
481        ovk: OutgoingViewingKey,
482        rng: &mut impl CryptoRng,
483    ) -> Transaction {
484        let enforcement =
485            zip212_enforcement(&Network::TestNetwork, BlockHeight::from_u32(MINED_HEIGHT));
486        let value = NoteValue::from_raw(VALUE.into_u64());
487        let rseed = generate_random_rseed(enforcement, rng);
488        let note = ::sapling::Note::from_parts(recipient, value, rseed);
489        let cv = ValueCommitment::derive(value, ValueCommitTrapdoor::random(&mut *rng));
490        let cmu = note.cmu();
491
492        let encryptor =
493            sapling_note_encryption(Some(ovk.into()), note, MemoBytes::empty().into_bytes(), rng);
494        let ephemeral_key = SaplingDomain::epk_bytes(encryptor.epk());
495        let enc_ciphertext = encryptor.encrypt_note_plaintext();
496        let out_ciphertext = encryptor.encrypt_outgoing_plaintext(&cv, &cmu, rng);
497
498        let output = ::sapling::bundle::OutputDescription::from_parts(
499            cv,
500            cmu,
501            ephemeral_key,
502            enc_ciphertext.0,
503            out_ciphertext,
504            [0u8; core::mem::size_of::<::sapling::bundle::GrothProofBytes>()],
505        );
506
507        let bundle = ::sapling::Bundle::from_parts(
508            vec![],
509            vec![output],
510            ZatBalance::const_from_i64(-(VALUE.into_u64() as i64)),
511            ::sapling::bundle::Authorized {
512                binding_sig: redjubjub::Signature::from([0u8; 64]),
513            },
514        )
515        .expect("the bundle has an output");
516
517        TransactionData::<Authorized>::from_parts(
518            TxVersion::V5,
519            BranchId::Nu5,
520            0,
521            BlockHeight::from_u32(MINED_HEIGHT + 100),
522            None,
523            None,
524            Some(bundle),
525            None,
526        )
527        .freeze()
528        .expect("the transaction data is well-formed")
529    }
530
531    /// Recovers the wallet's view of a transaction, returning the transfer type and value of each
532    /// Sapling output that could be decrypted.
533    fn decrypt_sapling(tx: &Transaction) -> Vec<(TransferType, Zatoshis)> {
534        let mut ufvks = HashMap::new();
535        ufvks.insert(0u32, wallet_ufvk());
536
537        decrypt_transaction(
538            &Network::TestNetwork,
539            Some(BlockHeight::from_u32(MINED_HEIGHT)),
540            None,
541            tx,
542            &ufvks,
543        )
544        .sapling_outputs()
545        .iter()
546        .map(|output| (output.transfer_type(), output.note_value()))
547        .collect()
548    }
549
550    /// A Sapling output is recovered under every OVK a ZIP 316-compliant sender might have chosen
551    /// for the transaction, not only the Sapling external-scope one.
552    #[test]
553    fn sapling_output_recovered_with_every_zip316_ovk() {
554        let mut rng = ChaChaRng::seed_from_u64(0);
555        let recipient = counterparty_ufvk()
556            .sapling()
557            .expect("UFVK has a Sapling item")
558            .default_address()
559            .1;
560
561        for (label, ovk) in zip316_sender_ovks(&wallet_ufvk()) {
562            let tx = sapling_tx_paying(recipient, ovk, &mut rng);
563            assert_eq!(
564                decrypt_sapling(&tx),
565                vec![(TransferType::Outgoing, VALUE)],
566                "output encrypted under the {label} OVK was not recovered"
567            );
568        }
569    }
570
571    /// An outgoing ciphertext that no wallet key can open is not reported.
572    #[test]
573    fn sapling_output_not_recovered_with_foreign_ovk() {
574        let mut rng = ChaChaRng::seed_from_u64(1);
575        let recipient = counterparty_ufvk()
576            .sapling()
577            .expect("UFVK has a Sapling item")
578            .default_address()
579            .1;
580
581        let tx = sapling_tx_paying(recipient, foreign_ovk(), &mut rng);
582        assert_eq!(decrypt_sapling(&tx), vec![]);
583    }
584
585    /// Builds a v5 transaction whose sole Orchard action creates a note paying `recipient`, with
586    /// the outgoing ciphertext encrypted under `ovk`.
587    ///
588    /// The action's spend half is vestigial here: it carries an arbitrary nullifier, whose only
589    /// role is to seed the note's `rho`. The zero-knowledge proof and signatures are dummies;
590    /// none of them participates in note decryption or outgoing-ciphertext recovery.
591    #[cfg(feature = "orchard")]
592    fn orchard_tx_paying(
593        recipient: ::orchard::Address,
594        ovk: OutgoingViewingKey,
595        rng: &mut impl CryptoRng,
596    ) -> Transaction {
597        use ::orchard::{
598            Anchor, Proof,
599            bundle::{Authorized as OrchardAuthorized, BundleVersion, Flags},
600            note::{
601                ExtractedNoteCommitment, Nullifier, RandomSeed, Rho, TransmittedNoteCiphertext,
602            },
603            note_encryption::OrchardNoteEncryption,
604            primitives::redpallas::{SigningKey, SpendAuth, VerificationKey},
605            value::{NoteValue as OrchardNoteValue, ValueCommitTrapdoor, ValueCommitment},
606        };
607        use nonempty::NonEmpty;
608
609        let mut nf_bytes = [0u8; 32];
610        rng.fill_bytes(&mut nf_bytes);
611        let nf_old = loop {
612            let nf = Nullifier::from_bytes(&nf_bytes);
613            if nf.is_some().into() {
614                break nf.unwrap();
615            }
616            rng.fill_bytes(&mut nf_bytes);
617        };
618        // `Rho::from_nf_old(nf)` is `Rho(nf.inner())`, which this reproduces through the
619        // public byte encodings so that the domain reconstructed from the action's revealed
620        // nullifier matches the one the note was created under.
621        let rho = Rho::from_bytes(&nf_old.to_bytes()).expect("a nullifier is a canonical Rho");
622        let rseed = loop {
623            let mut bytes = [0u8; 32];
624            rng.fill_bytes(&mut bytes);
625            let rseed = RandomSeed::from_bytes(bytes, &rho);
626            if rseed.is_some().into() {
627                break rseed.unwrap();
628            }
629        };
630
631        let value = OrchardNoteValue::from_raw(VALUE.into_u64());
632        let note =
633            ::orchard::Note::from_parts(recipient, value, rho, rseed, ::orchard::NoteVersion::V2)
634                .expect("the note parts are consistent");
635        let cmx = ExtractedNoteCommitment::from(note.commitment());
636        let cv_net = ValueCommitment::derive(
637            OrchardNoteValue::from_raw(0) - value,
638            ValueCommitTrapdoor::from_bytes([0u8; 32]).expect("zero is a canonical scalar"),
639        );
640
641        let encryptor = OrchardNoteEncryption::new(Some(ovk.into()), note, [0u8; 512]);
642        let encrypted_note = TransmittedNoteCiphertext {
643            epk_bytes: ::orchard::note_encryption::OrchardDomain::epk_bytes(encryptor.epk()).0,
644            enc_ciphertext: encryptor.encrypt_note_plaintext(),
645            out_ciphertext: encryptor.encrypt_outgoing_plaintext(&cv_net, &cmx, rng),
646        };
647
648        let rk = loop {
649            let mut rk_bytes = [0u8; 32];
650            rng.fill_bytes(&mut rk_bytes);
651            if let Ok(sk) = SigningKey::<SpendAuth>::try_from(rk_bytes) {
652                break VerificationKey::<SpendAuth>::from(&sk);
653            }
654        };
655
656        let action = ::orchard::Action::from_parts(
657            nf_old,
658            rk,
659            cmx,
660            encrypted_note,
661            cv_net,
662            ::orchard::primitives::redpallas::Signature::from([0u8; 64]),
663        )
664        .expect("the action parts are valid");
665
666        let bundle = ::orchard::Bundle::try_from_parts(
667            NonEmpty::new(action),
668            Flags::ENABLED,
669            ZatBalance::const_from_i64(-(VALUE.into_u64() as i64)),
670            Anchor::empty_tree(),
671            OrchardAuthorized::from_parts(
672                Proof::new(vec![0u8; Proof::expected_proof_size(1)]),
673                ::orchard::primitives::redpallas::Signature::from([0u8; 64]),
674            ),
675            BundleVersion::orchard_v2(),
676        )
677        .expect("the bundle parts are valid");
678
679        TransactionData::<Authorized>::from_parts(
680            TxVersion::V5,
681            BranchId::Nu5,
682            0,
683            BlockHeight::from_u32(MINED_HEIGHT + 100),
684            None,
685            None,
686            None,
687            Some(bundle),
688        )
689        .freeze()
690        .expect("the transaction data is well-formed")
691    }
692
693    /// Recovers the wallet's view of a transaction, returning the transfer type and value of each
694    /// Orchard output that could be decrypted.
695    #[cfg(feature = "orchard")]
696    fn decrypt_orchard_with(
697        ufvk: UnifiedFullViewingKey,
698        tx: &Transaction,
699    ) -> Vec<(TransferType, Zatoshis)> {
700        let mut ufvks = HashMap::new();
701        ufvks.insert(0u32, ufvk);
702
703        decrypt_transaction(
704            &Network::TestNetwork,
705            Some(BlockHeight::from_u32(MINED_HEIGHT)),
706            None,
707            tx,
708            &ufvks,
709        )
710        .orchard_outputs()
711        .iter()
712        .map(|output| {
713            let (note, _) = output.note();
714            (
715                output.transfer_type(),
716                Zatoshis::from_u64(note.value().inner()).expect("note value is in range"),
717            )
718        })
719        .collect()
720    }
721
722    #[cfg(feature = "orchard")]
723    fn decrypt_orchard(tx: &Transaction) -> Vec<(TransferType, Zatoshis)> {
724        decrypt_orchard_with(wallet_ufvk(), tx)
725    }
726
727    /// An Orchard action is recovered under every OVK a ZIP 316-compliant sender might have chosen
728    /// for the transaction, including the Sapling and transparent-derived ones that a sender
729    /// spending from those pools uses. This is the cross-pool case: an OVK is pool-agnostic input
730    /// to the outgoing-ciphertext KDF, so the pool an output lands in says nothing about which key
731    /// encrypted it.
732    #[cfg(feature = "orchard")]
733    #[test]
734    fn orchard_action_recovered_with_every_zip316_ovk() {
735        let mut rng = ChaChaRng::seed_from_u64(2);
736        let recipient = counterparty_ufvk()
737            .orchard()
738            .expect("UFVK has an Orchard item")
739            .address_at(0u32, Scope::External);
740
741        for (label, ovk) in zip316_sender_ovks(&wallet_ufvk()) {
742            let tx = orchard_tx_paying(recipient, ovk, &mut rng);
743            assert_eq!(
744                decrypt_orchard(&tx),
745                vec![(TransferType::Outgoing, VALUE)],
746                "action encrypted under the {label} OVK was not recovered"
747            );
748        }
749    }
750
751    /// An outgoing ciphertext that no wallet key can open is not reported.
752    #[cfg(feature = "orchard")]
753    #[test]
754    fn orchard_action_not_recovered_with_foreign_ovk() {
755        let mut rng = ChaChaRng::seed_from_u64(3);
756        let recipient = counterparty_ufvk()
757            .orchard()
758            .expect("UFVK has an Orchard item")
759            .address_at(0u32, Scope::External);
760
761        let tx = orchard_tx_paying(recipient, foreign_ovk(), &mut rng);
762        assert_eq!(decrypt_orchard(&tx), vec![]);
763    }
764
765    /// An account whose UFVK has no Orchard item still recovers an Orchard action that it funded
766    /// from its Sapling notes. Having no Orchard item only means the account cannot be the
767    /// *recipient* of an Orchard action; it can still be the sender, in which case the outgoing
768    /// ciphertext is encrypted under its Sapling OVK.
769    #[cfg(feature = "orchard")]
770    #[test]
771    fn orchard_action_recovered_by_ufvk_without_orchard_item() {
772        let mut rng = ChaChaRng::seed_from_u64(4);
773        let recipient = counterparty_ufvk()
774            .orchard()
775            .expect("UFVK has an Orchard item")
776            .address_at(0u32, Scope::External);
777
778        let wallet = wallet_ufvk();
779        let dfvk = wallet.sapling().expect("UFVK has a Sapling item").clone();
780        let sapling_only = UnifiedFullViewingKey::new(
781            #[cfg(feature = "transparent-inputs")]
782            None,
783            Some(dfvk.clone()),
784            #[cfg(feature = "orchard")]
785            None,
786        )
787        .expect("a Sapling-only UFVK is valid");
788
789        let tx = orchard_tx_paying(recipient, dfvk.to_ovk(Scope::External).into(), &mut rng);
790        assert_eq!(
791            decrypt_orchard_with(sapling_only, &tx),
792            vec![(TransferType::Outgoing, VALUE)]
793        );
794    }
795}