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/sprout.rs
Line
Count
Source
1
//! Sprout JoinSplit Groth16 `batch ⟺ single` verification equivalence.
2
//!
3
//! ## Read this before citing anything from this module
4
//!
5
//! **Zebra does not batch-verify Sprout JoinSplits.** `JOINSPLIT_VERIFIER` is a
6
//! bare `tower::service_fn` that calls `Item::verify_single` on each JoinSplit
7
//! (`groth16.rs:83-102`), with the comment *"We just need a Service to use: there
8
//! is no batch verification for JoinSplits"* and a pointer to the upstream issue
9
//! that proposed adding it, [ZcashFoundation/zebra#3127].
10
//!
11
//! **That issue is closed — `not planned`, 2022-03-15** — so this module does not
12
//! describe it as pending. The reasons given were that most JoinSplits sit below
13
//! the checkpoint verifier and never reach proof verification, so the gain would
14
//! be small; the closing comment redirects to the general performance tracker
15
//! [ZcashFoundation/zebra#3153] with *"can be done if we detect it's a
16
//! bottleneck"*. Upstream's source comment still links #3127 as though open.
17
//!
18
//! So the `batch ⟺ single` disagreement this module looks for **cannot occur in
19
//! Zebra today** — there is no batch to disagree with. What it covers is the
20
//! `bellman::groth16::batch` verifier under Sprout's parameters and real Sprout
21
//! proofs: the code JoinSplit verification would run on if batching were ever
22
//! switched on, and which the grant names as one of its four verifiers.
23
//!
24
//! That makes this the one pool where the oracle runs ahead of the deployment
25
//! rather than beside it — and, since the deployment is not scheduled, ahead of
26
//! a decision rather than of a release. Worth having either way: the gate is
27
//! cheaper to build now than to retrofit if batching is ever revisited. But
28
//! any report sentence that lets a reader think Zebra batches JoinSplits today
29
//! would be false, so this module states it here rather than leaving it to be
30
//! inferred.
31
//!
32
//! ## Two things are reproduced rather than called, and why that is safe
33
//!
34
//! `zebra-consensus` cannot enter this crate's dependency graph — it pulls in
35
//! `zebra-state` and therefore rocksdb — so two pieces of it are reproduced here
36
//! from `groth16.rs`:
37
//!
38
//! * the JoinSplit public-input encoding (`Item::from_joinsplit`, `:150-190`),
39
//! * the `h_sig` hash (`:112-131`), computed with the same crate and version.
40
//!
41
//! Both line ranges are in `zebra-consensus/src/primitives/groth16.rs` **at the
42
//! pinned base revision `f5c5277` (v6.3.0)**, which is what this crate builds
43
//! against. They are stated with the revision because upstream has since
44
//! rewritten that file: on `main`, `Item::from_joinsplit` is gone, replaced by a
45
//! free function `joinsplit_to_item` taking a `zcash_primitives` `JsDescription`
46
//! rather than Zebra's own `sprout::JoinSplit`, and `h_sig` now takes four bare
47
//! `[u8; 32]`. The encoding those functions compute is byte-for-byte the same, so
48
//! what is reproduced below is still what production computes — but a reader who
49
//! follows a bare line number to `main` lands in rewritten code, and a bare
50
//! symbol name does not resolve there at all.
51
//!
52
//! Reproduction is exactly the hazard this project keeps warning about: if the
53
//! encoding were wrong, **both** paths would receive the same wrong public inputs
54
//! and agree on rejecting everything — a green suite proving nothing. The guard
55
//! is that the corpus is real mainnet JoinSplits, which verify only if the
56
//! encoding is right. `real_joinsplits_verify` in `tests/sprout_agreement.rs` is
57
//! therefore not a smoke test; it is what makes every other assertion here mean
58
//! something.
59
//!
60
//! The verifying key is Zebra's own file, vendored byte-for-byte and checked
61
//! against its hash at load time — see [`SproutKeys::bundled`].
62
//!
63
//! [ZcashFoundation/zebra#3127]: https://github.com/ZcashFoundation/zebra/issues/3127
64
//! [ZcashFoundation/zebra#3153]: https://github.com/ZcashFoundation/zebra/issues/3153
65
66
use bellman::gadgets::multipack;
67
use bellman::groth16::{batch, prepare_verifying_key, PreparedVerifyingKey, Proof, VerifyingKey};
68
use bls12_381::Bls12;
69
70
use zebra_chain::primitives::ed25519;
71
use zebra_chain::primitives::Groth16Proof;
72
use zebra_chain::sprout::{JoinSplit, Nullifier, RandomSeed};
73
use zebra_chain::transaction::Transaction;
74
75
use crate::seeded_rng;
76
use crate::verifier::BatchVerifier;
77
78
/// One Sprout verification item: a JoinSplit's Groth16 proof and its primary
79
/// inputs, in the form both `bellman` paths consume.
80
pub struct SproutItem {
81
    item: batch::Item<Bls12>,
82
}
83
84
impl SproutItem {
85
    /// The underlying batch item, cloned. Both paths consume the item, so every
86
    /// use is a clone.
87
0
    pub fn batch_item(&self) -> batch::Item<Bls12> {
88
0
        self.item.clone()
89
0
    }
90
}
91
92
/// The Sprout JoinSplit verifying key, in both forms the two paths need.
93
///
94
/// The batch path takes the raw key; the independent path takes the prepared
95
/// one. Both are derived from the same bytes, so a disagreement between the
96
/// paths can never be blamed on the keys differing.
97
pub struct SproutKeys {
98
    vk: VerifyingKey<Bls12>,
99
    pvk: PreparedVerifyingKey<Bls12>,
100
}
101
102
/// Zebra's Sprout verifying key, vendored from
103
/// `zebra-consensus/src/primitives/groth16/sprout-groth16.vk` at the pinned
104
/// revision (`f5c5277f`). Re-hashed at the v6.2.3 -> v6.3.0 bump and found
105
/// byte-identical upstream, so this copy carries over unmodified.
106
///
107
/// Vendored rather than read from the dependency: `zebra-consensus` is not in
108
/// this crate's graph, and reaching into a cargo git checkout by path would not
109
/// survive a clean clone. 1,828 bytes, unmodified.
110
const SPROUT_VK_BYTES: &[u8] = include_bytes!("../vendor/sprout-groth16.vk");
111
112
/// Keyed BLAKE2b-256 digest of [`SPROUT_VK_BYTES`] as vendored, filled in from a
113
/// measured value (see the test below).
114
///
115
/// Checked at load time so the file cannot be swapped or corrupted silently.
116
/// A verifying key that changed without anyone noticing would not make this
117
/// suite fail — it would make every proof reject, and assertions about
118
/// *equivalence* rather than acceptance stay perfectly green while verifying
119
/// against the wrong key.
120
///
121
/// BLAKE2b rather than SHA-256 because `blake2b_simd` is already in the graph
122
/// for `h_sig`; a hand-written hash would be one more implementation that can
123
/// be wrong, in a crate whose entire argument is that it does not reimplement
124
/// the things it checks.
125
pub const SPROUT_VK_DIGEST: [u8; 32] = [
126
    0x36, 0x82, 0x89, 0xf0, 0xee, 0x6f, 0xa0, 0x18, 0xa5, 0xc2, 0x8b, 0xa8, 0x4b, 0x30, 0xb3, 0x58,
127
    0xc8, 0x5a, 0x2e, 0xe9, 0x89, 0x0d, 0xa9, 0x4b, 0xcd, 0xf6, 0xe6, 0xdb, 0xfa, 0xf5, 0x37, 0x9a,
128
];
129
130
impl SproutKeys {
131
    /// Zebra's Sprout verifying key, parsed exactly as `SproutParams::default`
132
    /// parses it (`groth16/params.rs:28`), after checking the vendored bytes
133
    /// against [`SPROUT_VK_DIGEST`].
134
114
    pub fn bundled() -> &'static Self {
135
        use std::sync::OnceLock;
136
        static KEYS: OnceLock<SproutKeys> = OnceLock::new();
137
114
        KEYS.get_or_init(|| {
138
2
            assert_eq!(
139
2
                vk_digest(SPROUT_VK_BYTES),
140
                SPROUT_VK_DIGEST,
141
0
                "vendored Sprout verifying key does not match its recorded hash: the file has \
142
0
                 been modified or replaced"
143
            );
144
2
            let vk = VerifyingKey::<Bls12>::read(SPROUT_VK_BYTES)
145
2
                .expect("vendored Sprout verifying key must parse");
146
2
            let pvk = prepare_verifying_key(&vk);
147
2
            SproutKeys { vk, pvk }
148
2
        })
149
114
    }
150
}
151
152
/// Digest of the vendored key bytes, for the integrity check in
153
/// [`SproutKeys::bundled`]. Personalised so the value cannot be confused with
154
/// any other digest of the same bytes.
155
2
fn vk_digest(data: &[u8]) -> [u8; 32] {
156
2
    blake2b_simd::Params::new()
157
2
        .hash_length(32)
158
2
        .personal(b"zbe-sprout-vk-v1")
159
2
        .hash(data)
160
2
        .as_bytes()
161
2
        .try_into()
162
2
        .expect("32 byte digest")
163
2
}
164
165
/// The `h_sig` hash function a JoinSplit's proof commits to
166
/// ([protocol spec §5.4.1.5][hsig]).
167
///
168
/// Reproduced from `groth16.rs:112-131`, using the same crate at the same
169
/// version, because `zebra-consensus` cannot be a dependency here.
170
///
171
/// [hsig]: https://zips.z.cash/protocol/protocol.pdf#hsigcrh
172
1.72k
pub fn h_sig(
173
1.72k
    random_seed: &RandomSeed,
174
1.72k
    nf1: &Nullifier,
175
1.72k
    nf2: &Nullifier,
176
1.72k
    joinsplit_pub_key: &ed25519::VerificationKeyBytes,
177
1.72k
) -> [u8; 32] {
178
1.72k
    blake2b_simd::Params::new()
179
1.72k
        .hash_length(32)
180
1.72k
        .personal(b"ZcashComputehSig")
181
1.72k
        .to_state()
182
1.72k
        .update(&(<[u8; 32]>::from(random_seed))[..])
183
1.72k
        .update(&(<[u8; 32]>::from(nf1))[..])
184
1.72k
        .update(&(<[u8; 32]>::from(nf2))[..])
185
1.72k
        .update(joinsplit_pub_key.as_ref())
186
1.72k
        .finalize()
187
1.72k
        .as_bytes()
188
1.72k
        .try_into()
189
1.72k
        .expect("32 byte array")
190
1.72k
}
191
192
/// Build the verification item for one JoinSplit, encoding its primary inputs
193
/// exactly as `Item::from_joinsplit` does (`groth16.rs:150-190` at the pinned
194
/// base `f5c5277`; upstream `main` has since renamed it `joinsplit_to_item` and
195
/// changed its input type — see this module's header).
196
///
197
/// `None` if the proof bytes do not decode — the same fail-closed outcome
198
/// production reaches there, by way of `TransactionError::MalformedGroth16`.
199
///
200
/// All JoinSplits in a transaction share one validating key, which is why it is
201
/// a separate argument rather than something the JoinSplit carries.
202
1.72k
pub fn item_from_joinsplit(
203
1.72k
    joinsplit: &JoinSplit<Groth16Proof>,
204
1.72k
    joinsplit_pub_key: &ed25519::VerificationKeyBytes,
205
1.72k
) -> Option<SproutItem> {
206
1.72k
    let rt: [u8; 32] = joinsplit.anchor.into();
207
1.72k
    let mac1: [u8; 32] = (&joinsplit.vmacs[0]).into();
208
1.72k
    let mac2: [u8; 32] = (&joinsplit.vmacs[1]).into();
209
1.72k
    let nf1: [u8; 32] = (&joinsplit.nullifiers[0]).into();
210
1.72k
    let nf2: [u8; 32] = (&joinsplit.nullifiers[1]).into();
211
1.72k
    let cm1: [u8; 32] = (&joinsplit.commitments[0]).into();
212
1.72k
    let cm2: [u8; 32] = (&joinsplit.commitments[1]).into();
213
1.72k
    let vpub_old = joinsplit.vpub_old.to_bytes();
214
1.72k
    let vpub_new = joinsplit.vpub_new.to_bytes();
215
216
1.72k
    let h_sig = h_sig(
217
1.72k
        &joinsplit.random_seed,
218
1.72k
        &joinsplit.nullifiers[0],
219
1.72k
        &joinsplit.nullifiers[1],
220
1.72k
        joinsplit_pub_key,
221
    );
222
223
    // Field order is consensus-critical and matches the reference implementation
224
    // (librustzcash `zcash_proofs/src/sprout.rs`), which is what `groth16.rs`
225
    // follows.
226
1.72k
    let mut public_input = Vec::with_capacity((32 * 8) + (8 * 2));
227
1.72k
    public_input.extend(rt);
228
1.72k
    public_input.extend(h_sig);
229
1.72k
    public_input.extend(nf1);
230
1.72k
    public_input.extend(mac1);
231
1.72k
    public_input.extend(nf2);
232
1.72k
    public_input.extend(mac2);
233
1.72k
    public_input.extend(cm1);
234
1.72k
    public_input.extend(cm2);
235
1.72k
    public_input.extend(vpub_old);
236
1.72k
    public_input.extend(vpub_new);
237
238
1.72k
    let public_input = multipack::bytes_to_bits(&public_input);
239
1.72k
    let primary_inputs = multipack::compute_multipacking(&public_input);
240
241
1.72k
    let proof = Proof::read(&joinsplit.zkproof.0[..]).ok()?;
242
243
1.17k
    Some(SproutItem {
244
1.17k
        item: batch::Item::from((proof, primary_inputs)),
245
1.17k
    })
246
1.72k
}
247
248
/// Every Groth16 JoinSplit item in a transaction.
249
///
250
/// Empty for transactions with no JoinSplits, and for those whose JoinSplits
251
/// carry BCTV14 proofs — pre-Sapling history that no shipping verifier accepts.
252
/// `sprout_groth16_joinsplits` makes that distinction; `joinsplit_count` does
253
/// not, which is how a corpus survey can overstate Sprout material several-fold.
254
///
255
/// **No shielded-only filter here**, unlike every other pool in this crate: a
256
/// Sprout Groth16 proof is not bound to a sighash, so a transparent input cannot
257
/// invalidate it. (The Ed25519 signature over the same JoinSplit *is* bound to
258
/// one — a different item stream, with a different usability rule.)
259
166
pub fn items_from_tx(tx: &Transaction) -> Vec<SproutItem> {
260
166
    let Some(pub_key) = tx.sprout_joinsplit_pub_key() else {
261
42
        return Vec::new();
262
    };
263
124
    tx.sprout_groth16_joinsplits()
264
1.72k
        .filter_map(|joinsplit| item_from_joinsplit(joinsplit, &pub_key))
265
124
        .collect()
266
166
}
267
268
/// Sprout JoinSplit Groth16 proofs, driven through `bellman::groth16::batch`.
269
///
270
/// See the module docs: Zebra verifies these one at a time today, and the issue
271
/// proposing batch support (#3127) was closed as not planned, so the batch side
272
/// of this verifier is a path Zebra's dependency can reach but Zebra does not
273
/// run.
274
pub struct Sprout;
275
276
impl BatchVerifier for Sprout {
277
    type Item = SproutItem;
278
    type Context = SproutKeys;
279
    const NAME: &'static str = "sprout (JoinSplit Groth16)";
280
281
    /// All proofs in one batch, one verdict per item.
282
    ///
283
    /// Every item receives the same verdict: `queue` cannot fail, and batch
284
    /// verification is one equation over the whole set. Structurally identical to
285
    /// [`crate::redjubjub`], and for the same reason — comparing this against the
286
    /// singles position by position would report a false reject for every valid
287
    /// proof sharing a batch with a bad one, which is what batch verification
288
    /// means rather than a finding.
289
224
    fn validate_batch(items: &[&Self::Item], ctx: &Self::Context, seed: u64) -> Vec<bool> {
290
224
        let mut verifier = batch::Verifier::new();
291
2.57k
        for item in items {
292
2.35k
            verifier.queue(item.item.clone());
293
2.35k
        }
294
224
        let shared = verifier.verify(seeded_rng(seed), &ctx.vk).is_ok();
295
224
        vec![shared; items.len()]
296
224
    }
297
298
    /// One proof through the batch machinery alone — the aggregation path at
299
    /// N=1, which is what makes layer 1 a comparison of batch sizes rather than
300
    /// of algorithms.
301
1.79k
    fn validate_one(item: &Self::Item, ctx: &Self::Context, seed: u64) -> bool {
302
1.79k
        let mut verifier = batch::Verifier::new();
303
1.79k
        verifier.queue(item.item.clone());
304
1.79k
        verifier.verify(seeded_rng(seed), &ctx.vk).is_ok()
305
1.79k
    }
306
307
    /// The independent path: `batch::Item::verify_single`, which runs
308
    /// `bellman::verify_proof` against the prepared key — no randomised linear
309
    /// combination, no RNG.
310
    ///
311
    /// bellman documents it as *"non-batched verification ... useful for
312
    /// implementing fallback logic"*, and it is what Zebra calls for every
313
    /// JoinSplit today. So, as with RedJubjub, layer 2 here is production code
314
    /// rather than a second opinion nobody runs.
315
    ///
316
    /// The two paths share the primary-input encoding built in
317
    /// [`item_from_joinsplit`] and diverge at the verification equation: a
318
    /// randomised linear combination checked with one multi-Miller loop, against
319
    /// a per-proof pairing check. An error in the encoding is invisible to this
320
    /// check, which is why the corpus has to be real proofs that must verify.
321
0
    fn validate_one_independent(item: &Self::Item, ctx: &Self::Context) -> Option<bool> {
322
0
        Some(item.item.clone().verify_single(&ctx.pvk).is_ok())
323
0
    }
324
}
325
326
#[cfg(test)]
327
mod tests {
328
    use super::*;
329
330
    /// The vendored key parses and still matches its recorded digest.
331
    ///
332
    /// This is the assertion that makes every other Sprout result meaningful: a
333
    /// swapped key would reject every proof, and equivalence assertions would
334
    /// stay green throughout.
335
    #[test]
336
    fn vendored_verifying_key_is_intact() {
337
        assert_eq!(
338
            vk_digest(SPROUT_VK_BYTES),
339
            SPROUT_VK_DIGEST,
340
            "vendored Sprout verifying key digest changed"
341
        );
342
        assert_eq!(SPROUT_VK_BYTES.len(), 1828);
343
        // Forces the parse and the assertion inside `bundled`.
344
        let _ = SproutKeys::bundled();
345
    }
346
}