/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 | | } |