Skip to main content

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}