Coverage Report

Created: 2026-09-28 13:45

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/src/zebra-batch-equivalence/src/sapling.rs
Line
Count
Source
1
//! Sapling `batch ⟺ single` verification equivalence.
2
//!
3
//! Zebra verifies Sapling through one `sapling_crypto::BatchValidator`
4
//! (`zebra-consensus::primitives::sapling`, v6.3.0 — byte-identical to v6.2.3),
5
//! which internally keeps
6
//! three sub-batches: the spend Groth16 proofs, the output Groth16 proofs, and
7
//! the RedJubjub signatures. That is why Zebra's standalone `redjubjub` verifier
8
//! service has no production caller — Sapling's signatures are verified here,
9
//! inside the proof verifier, not beside it.
10
//!
11
//! ## The one place this must not copy Orchard
12
//!
13
//! Orchard's `add_bundle` returns a `Result` and leaves nothing behind when it
14
//! rejects, so M1 could treat a failed enqueue as failing the whole batch. Sapling's
15
//! [`BatchValidator::check_bundle`] returns a plain `bool`, and its own documentation
16
//! says what happens on the way to `false`:
17
//!
18
//! > *"some or all of the proofs and signatures from this bundle **may have already
19
//! > been added to the batch** even if it fails other consensus rules."*
20
//!
21
//! Following the implementation (`sapling-crypto-0.7.0/src/verifier/batch.rs`): it
22
//! walks the spends, queueing each one that passes its consensus checks, and returns
23
//! `false` the moment one does not — leaving everything queued so far in the shared
24
//! batch. Signatures are queued only at the very end, so a bundle that fails partway
25
//! contributes *some* of its proofs and *none* of its signatures.
26
//!
27
//! Zebra does not contain that residue; it broadcasts it. The failing item errors
28
//! immediately, the batch keeps going, and `BatchControl::Flush` validates the
29
//! polluted batch and sends the one result to every other item still waiting
30
//! (`sapling.rs:104-118`).
31
//!
32
//! So [`Sapling::validate_batch`] deliberately does **not** stop at the first
33
//! `check_bundle` returning false. Stopping would skip the polluted `validate` call
34
//! entirely — which is exactly the code path worth testing, and the only place the
35
//! question "can one bad bundle change a different, valid bundle's verdict?" can
36
//! even be asked.
37
//!
38
//! No modifications to Zebra or to `sapling-crypto`: both paths here are public APIs.
39
40
use std::sync::{Arc, OnceLock};
41
42
use bellman::groth16::Proof;
43
// `ExtendedPoint::from_bytes` is a `GroupEncoding` method, not an inherent one.
44
use group::GroupEncoding;
45
use sapling_crypto::bundle::Authorized;
46
use sapling_crypto::circuit::{
47
    OutputVerifyingKey, PreparedOutputVerifyingKey, PreparedSpendVerifyingKey, SpendVerifyingKey,
48
};
49
use sapling_crypto::{BatchValidator, Bundle, SaplingVerificationContext};
50
use zcash_proofs::prover::LocalTxProver;
51
52
use zebra_chain::parameters::NetworkUpgrade;
53
use zebra_chain::transaction::{HashType, Transaction};
54
use zebra_chain::transparent;
55
56
use crate::verifier::BatchVerifier;
57
use crate::{seeded_rng, SigHash, ZatBalance};
58
59
/// The authorized Sapling bundle type Zebra hands us.
60
pub type SaplingBundle = Bundle<Authorized, ZatBalance>;
61
62
/// One Sapling verification item: the bundle plus the sighash its signatures are
63
/// bound to. The exact pair carried inside `zebra-consensus`'s `sapling::Item`.
64
///
65
/// `Clone` because the adversarial generator builds tampered variants from valid
66
/// items and needs the original intact to compare against.
67
#[derive(Clone)]
68
pub struct SaplingItem {
69
    /// The authorized Sapling bundle (spend/output proofs, spend-auth and binding
70
    /// signatures).
71
    pub bundle: SaplingBundle,
72
    /// The sighash the bundle's signatures are over.
73
    pub sighash: SigHash,
74
}
75
76
impl SaplingItem {
77
    /// Number of Groth16 proofs this bundle contributes to a batch: one per spend
78
    /// plus one per output.
79
    ///
80
    /// Worth stating explicitly because Zebra's batch size limit does *not* count
81
    /// them. `MAX_BATCH_SIZE = 64` is applied through `RequestWeight`, which Sapling
82
    /// leaves at the default weight of 1 per **bundle** (only halo2 overrides it, to
83
    /// count actions). A batch of 64 Sapling bundles therefore carries an unbounded
84
    /// number of proofs, while a batch of Orchard bundles is capped at 64 actions.
85
0
    pub fn proof_count(&self) -> usize {
86
0
        self.bundle.shielded_spends().len() + self.bundle.shielded_outputs().len()
87
0
    }
88
}
89
90
/// The Sapling verifying keys, in both forms the two paths need.
91
///
92
/// The batch path takes the plain keys; the independent path takes the prepared
93
/// ones (precomputations for verifying proofs individually). Both derive from the
94
/// same parameters, so a disagreement between the paths can never be blamed on the
95
/// keys differing.
96
pub struct SaplingKeys {
97
    spend_vk: SpendVerifyingKey,
98
    output_vk: OutputVerifyingKey,
99
    prepared_spend_vk: PreparedSpendVerifyingKey,
100
    prepared_output_vk: PreparedOutputVerifyingKey,
101
}
102
103
impl SaplingKeys {
104
    /// The bundled Sapling parameters, obtained exactly as Zebra obtains them
105
    /// (`LocalTxProver::bundled`, `sapling.rs:32`).
106
    ///
107
    /// Despite the name, this downloads nothing: the `bundled-prover` feature pulls
108
    /// the parameters in as the `wagyu-zcash-parameters` crate. Cached, because
109
    /// parsing them is slow and every call would otherwise redo it.
110
0
    pub fn bundled() -> &'static Self {
111
        static KEYS: OnceLock<SaplingKeys> = OnceLock::new();
112
0
        KEYS.get_or_init(|| {
113
0
            let prover = LocalTxProver::bundled();
114
0
            let (spend_vk, output_vk) = prover.verifying_keys();
115
0
            let prepared_spend_vk = spend_vk.prepare();
116
0
            let prepared_output_vk = output_vk.prepare();
117
0
            SaplingKeys {
118
0
                spend_vk,
119
0
                output_vk,
120
0
                prepared_spend_vk,
121
0
                prepared_output_vk,
122
0
            }
123
0
        })
124
0
    }
125
}
126
127
/// Extract the Sapling item from a transaction, using the transaction's own
128
/// network upgrade for the sighash. `None` for transactions with no Sapling
129
/// bundle.
130
///
131
/// A transaction yields at most one Sapling item, unlike Orchard under NU6.3,
132
/// where a v6 transaction can carry both an Orchard-pool and an Ironwood-pool
133
/// bundle.
134
1.58k
pub fn item_from_tx(tx: &Transaction) -> Option<SaplingItem> {
135
1.58k
    item_from_tx_with_nu(tx, tx.network_upgrade()?)
136
1.58k
}
137
138
/// Extract the Sapling item using an explicit network upgrade for the sighash
139
/// (a fixed upgrade for a corpus of known era). See [`item_from_tx`].
140
///
141
/// Transparent inputs are the caller's problem, not this function's: the sighash
142
/// is computed against an empty prevout set, which is correct for a
143
/// shielded-only transaction and wrong for one spending transparent inputs,
144
/// whose ZIP-243/244 sighash folds in prevouts a bare transaction does not
145
/// carry. Corpus loaders filter those out for the signature-bearing paths.
146
/// (Sprout's Groth16 proofs are the exception — they are not bound to a sighash
147
/// at all, so that filter would only discard usable material there.)
148
1.31k
pub fn item_from_tx_with_nu(tx: &Transaction, nu: NetworkUpgrade) -> Option<SaplingItem> {
149
1.31k
    if !tx.has_sapling_shielded_data() {
150
173
        return None;
151
1.14k
    }
152
1.14k
    let empty_prevouts: Arc<Vec<transparent::Output>> = Arc::new(Vec::new());
153
1.14k
    let sighasher = tx.sighasher(nu, empty_prevouts).ok()?;
154
1.14k
    let bundle = sighasher.sapling_bundle()?;
155
1.14k
    let sighash = sighasher.sighash(HashType::ALL, None);
156
157
1.14k
    Some(SaplingItem {
158
1.14k
        bundle,
159
1.14k
        sighash: SigHash(sighash.0),
160
1.14k
    })
161
1.31k
}
162
163
/// Zebra's Sapling verifier — Groth16 spend/output proofs and RedJubjub
164
/// signatures, driven through `sapling_crypto::BatchValidator`.
165
///
166
/// Leaves [`BatchVerifier::item_weight`] at the default of 1 per **bundle**,
167
/// because that is what Zebra does: `sapling::Item` takes the blanket
168
/// `RequestWeight` impl and never overrides it. Worth naming rather than leaving
169
/// as a silent default, since the consequence is the asymmetry the tower-layer
170
/// tests exist to pin — a full Sapling batch is 64 bundles, each carrying an
171
/// unbounded [`SaplingItem::proof_count`], so the number of Groth16 proofs in
172
/// one batch is not bounded at all. The Orchard batch beside it, under the same
173
/// constant, is capped at 64 actions.
174
pub struct Sapling;
175
176
impl BatchVerifier for Sapling {
177
    type Item = SaplingItem;
178
    type Context = SaplingKeys;
179
    const NAME: &'static str = "sapling (Groth16 + RedJubjub)";
180
181
    /// All bundles through one validator, one verdict per bundle.
182
    ///
183
    /// **Does not stop at the first rejected bundle** — see the module docs. A
184
    /// bundle that fails `check_bundle` has already contributed part of itself to
185
    /// the shared batch, and production runs `validate` over that polluted batch
186
    /// anyway; short-circuiting would silently skip the path being tested.
187
0
    fn validate_batch(items: &[&Self::Item], ctx: &Self::Context, seed: u64) -> Vec<bool> {
188
0
        let mut bv = BatchValidator::new();
189
0
        let checked: Vec<bool> = items
190
0
            .iter()
191
0
            .map(|item| bv.check_bundle(item.bundle.clone(), item.sighash.0))
192
0
            .collect();
193
194
0
        let shared = bv.validate(&ctx.spend_vk, &ctx.output_vk, seeded_rng(seed));
195
196
        // Mirrors `sapling.rs:104-118`: a bundle rejected at check time fails on its
197
        // own; every bundle that passed receives the batch's single verdict.
198
0
        checked.into_iter().map(|ok| ok && shared).collect()
199
0
    }
200
201
    /// One bundle alone, mirroring `zebra-consensus`'s `sapling::verify_single`
202
    /// (`sapling.rs:175`) — which is itself a batch of one, not a separate
203
    /// implementation. That is why [`Self::validate_one_independent`] exists.
204
0
    fn validate_one(item: &Self::Item, ctx: &Self::Context, seed: u64) -> bool {
205
0
        let mut bv = BatchValidator::new();
206
0
        if !bv.check_bundle(item.bundle.clone(), item.sighash.0) {
207
0
            return false;
208
0
        }
209
0
        bv.validate(&ctx.spend_vk, &ctx.output_vk, seeded_rng(seed))
210
0
    }
211
212
    /// The independent path: `SaplingVerificationContext` checks each spend and
213
    /// output on its own, calling `bellman`'s `verify_proof` per proof and
214
    /// `rk.verify` per signature, then reconciles the value balance and binding
215
    /// signature in `final_check`. Zebra never runs it.
216
    ///
217
    /// ## Exactly where the two paths diverge, and where they do not
218
    ///
219
    /// Worth stating precisely, because the divergence is narrower than "a
220
    /// different implementation" suggests and the report has to survive a reviewer
221
    /// reading `sapling-crypto`'s source. Both paths are thin shells over the
222
    /// **same** `SaplingVerificationContextInner`, differing only in the closures
223
    /// they hand it — queue-into-a-batch versus verify-here. Everything that
224
    /// happens outside those closures is one body of code shared by both:
225
    ///
226
    /// * the small-order rejections of `rk` and of an output's ephemeral key,
227
    /// * the `cv_sum` accumulation and the `into_bvk` derivation of the binding key,
228
    /// * the construction of each proof's public inputs.
229
    ///
230
    /// What genuinely differs is the verification algebra the closures reach:
231
    /// `groth16::batch::Verifier`'s randomized linear combination against
232
    /// `bellman::verify_proof` per proof, and `redjubjub::batch::Verifier` against
233
    /// `rk.verify` per signature.
234
    ///
235
    /// So layer 2 has power over the verification equations and **not** over the
236
    /// consensus checks or the public-input construction — a bug there would fail
237
    /// both paths identically and this check would report a clean `Agree(false)`.
238
    /// The consequence for the adversarial corpus is direct: tampering with a
239
    /// value balance, an ephemeral key, or an `rk` into small order exercises only
240
    /// shared code, and the two paths agreeing proves nothing. Proof bytes and
241
    /// signature bytes are the two inputs that reach the diverging half.
242
    ///
243
    /// Consumes no randomness, which is what makes it deterministic where the
244
    /// batch path is seeded.
245
    ///
246
    /// Field extraction deliberately mirrors `check_bundle` (`verifier/batch.rs`),
247
    /// so the two paths are fed byte-identical inputs and any disagreement is about
248
    /// the verification, not about how the bundle was read.
249
0
    fn validate_one_independent(item: &Self::Item, ctx: &Self::Context) -> Option<bool> {
250
0
        let mut vctx = SaplingVerificationContext::new();
251
0
        let sighash = item.sighash.0;
252
253
0
        for spend in item.bundle.shielded_spends() {
254
0
            let Ok(zkproof) = Proof::read(&spend.zkproof()[..]) else {
255
                // Undecodable proof: the batch path returns false here too.
256
0
                return Some(false);
257
            };
258
0
            let passed = vctx.check_spend(
259
0
                spend.cv(),
260
0
                *spend.anchor(),
261
0
                &spend.nullifier().0,
262
0
                *spend.rk(),
263
0
                &sighash,
264
0
                *spend.spend_auth_sig(),
265
0
                zkproof,
266
0
                &ctx.prepared_spend_vk,
267
            );
268
0
            if !passed {
269
0
                return Some(false);
270
0
            }
271
        }
272
273
0
        for output in item.bundle.shielded_outputs() {
274
0
            let epk = jubjub::ExtendedPoint::from_bytes(&output.ephemeral_key().0);
275
0
            let epk: Option<jubjub::ExtendedPoint> = epk.into();
276
0
            let Some(epk) = epk else {
277
0
                return Some(false);
278
            };
279
0
            let Ok(zkproof) = Proof::read(&output.zkproof()[..]) else {
280
0
                return Some(false);
281
            };
282
0
            let passed = vctx.check_output(
283
0
                output.cv(),
284
0
                *output.cmu(),
285
0
                epk,
286
0
                zkproof,
287
0
                &ctx.prepared_output_vk,
288
            );
289
0
            if !passed {
290
0
                return Some(false);
291
0
            }
292
        }
293
294
0
        Some(vctx.final_check(
295
0
            *item.bundle.value_balance(),
296
0
            &sighash,
297
0
            item.bundle.authorization().binding_sig,
298
0
        ))
299
0
    }
300
}