zcash_client_backend/data_api/zip318.rs
1//! Assembling [ZIP 318] classification evidence from a decrypted transaction.
2//!
3//! This is the evidence source a wallet uses in production, and the only one that runs against a
4//! transaction the wallet did not build. It lives here, in the backend-generic layer, rather than
5//! in a store, because [`store_decrypted_tx`] is where the parsed transaction and the decrypted
6//! outputs are in hand at the same moment; a store that re-derived the evidence from its own rows
7//! would be a second implementation of the predicate to keep in step with this one.
8//!
9//! What it can observe is weaker than what the same predicate sees on an unproven transaction, for
10//! a reason that is inherent rather than incidental: on chain, a zero-valued padding dummy and an
11//! unrelated party's output are equally undecryptable, so no amount of work distinguishes them.
12//! See [`Zip318Evidence::source_is_send_to_self`] for how the clause weakens, and [`classify`] for
13//! why weakening it is sound: it admits more, never less.
14//!
15//! [ZIP 318]: https://zips.z.cash/zip-0318
16//! [`store_decrypted_tx`]: crate::data_api::ll::wallet::store_decrypted_tx
17//! [`classify`]: zcash_protocol::zip318::classify
18//! [`Zip318Evidence::source_is_send_to_self`]: zcash_protocol::zip318::Zip318Evidence::source_is_send_to_self
19
20use zcash_primitives::transaction::Transaction;
21use zcash_protocol::value::Zatoshis;
22use zcash_protocol::zip318::{PoolMigrationConstants, Zip318Classification, Zip318Evidence};
23
24use crate::decrypt::{DecryptedOutput, TransferType};
25
26/// Classifies a decrypted transaction against [ZIP 318].
27///
28/// `orchard_outputs` and `ironwood_outputs` are the outputs the wallet could decrypt, as
29/// [`DecryptedTransaction`] reports them; an output absent from both is one the wallet cannot
30/// attribute, which is not the same as one belonging to somebody else.
31///
32/// [ZIP 318]: https://zips.z.cash/zip-0318
33/// [`DecryptedTransaction`]: crate::data_api::DecryptedTransaction
34#[cfg(feature = "orchard")]
35pub fn classify_decrypted_tx<AccountId, C>(
36 tx: &Transaction,
37 orchard_outputs: &[DecryptedOutput<(orchard::Note, orchard::ValuePool), AccountId>],
38 ironwood_outputs: &[DecryptedOutput<(orchard::Note, orchard::ValuePool), AccountId>],
39 constants: &C,
40) -> Zip318Classification
41where
42 C: PoolMigrationConstants + ?Sized,
43{
44 zcash_protocol::zip318::classify(
45 &evidence_from_decrypted_tx(tx, orchard_outputs, ironwood_outputs, constants),
46 constants,
47 )
48}
49
50/// The evidence behind [`classify_decrypted_tx`].
51#[cfg(feature = "orchard")]
52fn evidence_from_decrypted_tx<AccountId, C>(
53 tx: &Transaction,
54 orchard_outputs: &[DecryptedOutput<(orchard::Note, orchard::ValuePool), AccountId>],
55 ironwood_outputs: &[DecryptedOutput<(orchard::Note, orchard::ValuePool), AccountId>],
56 constants: &C,
57) -> Zip318Evidence
58where
59 C: PoolMigrationConstants + ?Sized,
60{
61 // The expiry clause takes the height-independent form, deliberately. The reference-height form
62 // cannot be evaluated before the transaction is mined, and judging it against the chain tip
63 // instead would let the answer change once the transaction was mined in a later modulus period,
64 // contradicting a decision already recorded.
65 //
66 // Neither confirmatory clause is answerable here, so both are left unanswered. Resolving the
67 // anchor to a height needs the wallet's retained boundary checkpoints, and the fee needs the
68 // value of inputs the wallet may not own; a store that has both may answer them and narrow the
69 // result.
70 // An absent bundle contributes no actions, which is what a preparation transaction's
71 // destination pool looks like.
72 Zip318Evidence::default()
73 .with_source_actions(Some(tx.orchard_bundle().map_or(0, |b| b.actions().len())))
74 .with_destination_actions(Some(tx.ironwood_bundle().map_or(0, |b| b.actions().len())))
75 .with_other_bundles_present(Some(other_bundles_present(tx)))
76 .with_source_is_send_to_self(Some(is_send_to_self(orchard_outputs)))
77 .with_sole_destination_value(sole_destination_value(ironwood_outputs))
78 .with_expiry_is_canonical(Some(
79 constants.is_canonical_expiry_value(tx.expiry_height()),
80 ))
81}
82
83/// Whether the transaction carries a transparent or Sapling bundle. No ZIP 318 transaction carries
84/// either, so this is a refutation on its own.
85#[cfg(feature = "orchard")]
86fn other_bundles_present(tx: &Transaction) -> bool {
87 tx.transparent_bundle()
88 .is_some_and(|b| !b.vin.is_empty() || !b.vout.is_empty())
89 || tx
90 .sapling_bundle()
91 .is_some_and(|b| !b.shielded_spends().is_empty() || !b.shielded_outputs().is_empty())
92}
93
94/// Whether the source-pool outputs are consistent with a send-to-self.
95///
96/// This is the weakened clause described in the module documentation. It cannot be "every
97/// value-carrying output is internal", because the outputs the wallet cannot decrypt are exactly
98/// the ones it cannot classify, and a padding dummy is one of those. What it can say is that the
99/// wallet received something on its own internal address here and received nothing on an external
100/// one, which is the strongest sound reading available from chain data.
101#[cfg(feature = "orchard")]
102fn is_send_to_self<AccountId>(
103 orchard_outputs: &[DecryptedOutput<(orchard::Note, orchard::ValuePool), AccountId>],
104) -> bool {
105 let mut any_internal = false;
106 for output in orchard_outputs {
107 match output.transfer_type() {
108 TransferType::AccountInternal => any_internal = true,
109 // An output on one of the account's own external addresses, or one the wallet sent to
110 // somebody else, both mean this is not a send-to-self.
111 TransferType::Incoming | TransferType::Outgoing | TransferType::WalletInternal => {
112 return false;
113 }
114 }
115 }
116 any_internal
117}
118
119/// The value of the sole destination-pool output, when the wallet can attribute exactly one.
120///
121/// A crossing the wallet cannot decrypt at all yields `None` (unknown), not a refutation: a
122/// stranger's canonical crossing is invisible here, and claiming otherwise would be a judgement on
123/// evidence we do not have.
124#[cfg(feature = "orchard")]
125fn sole_destination_value<AccountId>(
126 ironwood_outputs: &[DecryptedOutput<(orchard::Note, orchard::ValuePool), AccountId>],
127) -> Option<Zatoshis> {
128 let [output] = ironwood_outputs else {
129 return None;
130 };
131
132 let (note, _pool) = output.note();
133
134 Zatoshis::from_u64(note.value().inner()).ok()
135}