Skip to main content

voting_circuits/
shares_hash.rs

1//! Shared circuit gadget for the shares-hash computation used in ZKP #2 and ZKP #3.
2//!
3//! This module is the authoritative in-tree definition of the two-level
4//! encrypted-share hash. Both the vote-proof circuit (ZKP #2, condition 10) and
5//! the share-reveal circuit (ZKP #3, condition 3) call this implementation
6//! rather than maintaining separate formula copies:
7//!
8//! ```text
9//! share_comm_i = Poseidon(blind_i, c1_i_x, c2_i_x, c1_i_y, c2_i_y)   for i ∈ 0..16
10//! shares_hash  = Poseidon(share_comm_0, …, share_comm_15)
11//! ```
12//!
13//! The y-coordinates are included to bind each share commitment to the exact
14//! curve point, preventing ciphertext sign-malleability attacks where an
15//! adversary negates ElGamal ciphertext points without invalidating the ZKP.
16//!
17//! `shares_hash` is a reusable internal circuit value, not a public instance
18//! by itself. ZKP #2 binds it to the verifier only by feeding it into the
19//! public vote commitment, while ZKP #3 binds it transitively through the same
20//! vote commitment tree path.
21//!
22//! This module extracts those constraints into a single, auditable gadget so
23//! that both circuits provably execute the same hash logic.
24
25use halo2_gadgets::poseidon::{
26    primitives::{self as poseidon, ConstantLength},
27    Hash as PoseidonHash, Pow5Chip as PoseidonChip,
28};
29use halo2_proofs::{
30    circuit::{AssignedCell, Layouter},
31    plonk,
32};
33use itertools::Itertools;
34use pasta_curves::pallas;
35
36/// Native per-share blinded commitment:
37///
38/// ```text
39/// share_comm = Poseidon(blind, c1_x, c2_x, c1_y, c2_y)
40/// ```
41///
42/// The y-coordinates bind the commitment to the exact curve point, preventing
43/// ciphertext sign-malleability. The blind factor prevents anyone who sees the
44/// encrypted shares on-chain from recomputing `shares_hash` and linking it to a
45/// specific vote commitment.
46pub fn share_commitment(
47    blind: pallas::Base,
48    c1_x: pallas::Base,
49    c2_x: pallas::Base,
50    c1_y: pallas::Base,
51    c2_y: pallas::Base,
52) -> pallas::Base {
53    poseidon::Hash::<_, poseidon::P128Pow5T3, ConstantLength<5>, 3, 2>::init()
54        .hash([blind, c1_x, c2_x, c1_y, c2_y])
55}
56
57/// Native full two-level shares hash:
58///
59/// ```text
60/// share_comm_i = Poseidon(blind_i, c1_i_x, c2_i_x, c1_i_y, c2_i_y)   for i ∈ 0..16
61/// shares_hash  = Poseidon(share_comm_0, …, share_comm_15)
62/// ```
63///
64/// Native counterpart of [`compute_shares_hash_in_circuit`].
65pub fn shares_hash(
66    share_blinds: [pallas::Base; 16],
67    enc_share_c1_x: [pallas::Base; 16],
68    enc_share_c2_x: [pallas::Base; 16],
69    enc_share_c1_y: [pallas::Base; 16],
70    enc_share_c2_y: [pallas::Base; 16],
71) -> pallas::Base {
72    let comms: [pallas::Base; 16] = core::array::from_fn(|i| {
73        share_commitment(
74            share_blinds[i],
75            enc_share_c1_x[i],
76            enc_share_c2_x[i],
77            enc_share_c1_y[i],
78            enc_share_c2_y[i],
79        )
80    });
81    shares_hash_from_comms(comms)
82}
83
84/// Computes a single blinded per-share commitment in-circuit:
85///
86/// ```text
87/// share_comm = Poseidon(blind, c1_x, c2_x, c1_y, c2_y)
88/// ```
89///
90/// The y-coordinates bind the commitment to the exact curve point, preventing
91/// ciphertext sign-malleability. The `index` is used only for namespace labels
92/// and has no effect on the constraint system.
93pub(crate) fn hash_share_commitment_in_circuit(
94    chip: PoseidonChip<pallas::Base, 3, 2>,
95    mut layouter: impl Layouter<pallas::Base>,
96    blind: AssignedCell<pallas::Base, pallas::Base>,
97    enc_c1_x: AssignedCell<pallas::Base, pallas::Base>,
98    enc_c2_x: AssignedCell<pallas::Base, pallas::Base>,
99    enc_c1_y: AssignedCell<pallas::Base, pallas::Base>,
100    enc_c2_y: AssignedCell<pallas::Base, pallas::Base>,
101    index: usize,
102) -> Result<AssignedCell<pallas::Base, pallas::Base>, plonk::Error> {
103    let hasher =
104        PoseidonHash::<pallas::Base, _, poseidon::P128Pow5T3, ConstantLength<5>, 3, 2>::init(
105            chip,
106            layouter.namespace(|| format!("share_comm_{index} Poseidon init")),
107        )?;
108    hasher.hash(
109        layouter.namespace(|| {
110            format!("share_comm_{index} = Poseidon(blind, c1_x, c2_x, c1_y, c2_y)[{index}]")
111        }),
112        [blind, enc_c1_x, enc_c2_x, enc_c1_y, enc_c2_y],
113    )
114}
115
116/// Computes the two-level shares hash in-circuit:
117///
118/// ```text
119/// share_comm_i = Poseidon(blind_i, c1_i_x, c2_i_x, c1_i_y, c2_i_y)   for i ∈ 0..16
120/// shares_hash  = Poseidon(share_comm_0, …, share_comm_15)
121/// ```
122///
123/// # Arguments
124///
125/// * `poseidon_chip` — A closure that returns a fresh `PoseidonChip` each time
126///   it is called. It is called 17 times: once per per-share hash and once for
127///   the outer hash. Typically `|| config.poseidon_chip()`.
128/// * `layouter` — The circuit layouter.
129/// * `blinds` — The 16 per-share blind factors.
130/// * `enc_c1_x` — The 16 El Gamal `C1` x-coordinates.
131/// * `enc_c2_x` — The 16 El Gamal `C2` x-coordinates.
132/// * `enc_c1_y` — The 16 El Gamal `C1` y-coordinates.
133/// * `enc_c2_y` — The 16 El Gamal `C2` y-coordinates.
134///
135/// Returns the internal `shares_hash` cell. The caller is responsible for
136/// consuming that cell in a public binding, such as the vote commitment hash;
137/// this gadget does not constrain the result to an instance column.
138pub(crate) fn compute_shares_hash_in_circuit(
139    poseidon_chip: impl Fn() -> PoseidonChip<pallas::Base, 3, 2>,
140    mut layouter: impl Layouter<pallas::Base>,
141    blinds: [AssignedCell<pallas::Base, pallas::Base>; 16],
142    enc_c1_x: [AssignedCell<pallas::Base, pallas::Base>; 16],
143    enc_c2_x: [AssignedCell<pallas::Base, pallas::Base>; 16],
144    enc_c1_y: [AssignedCell<pallas::Base, pallas::Base>; 16],
145    enc_c2_y: [AssignedCell<pallas::Base, pallas::Base>; 16],
146) -> Result<AssignedCell<pallas::Base, pallas::Base>, plonk::Error> {
147    let share_comms: [_; 16] = IntoIterator::into_iter(blinds)
148        .zip_eq(enc_c1_x)
149        .zip_eq(enc_c2_x)
150        .zip_eq(enc_c1_y)
151        .zip_eq(enc_c2_y)
152        .enumerate()
153        .map(|(i, ((((blind, c1x), c2x), c1y), c2y))| {
154            hash_share_commitment_in_circuit(
155                poseidon_chip(),
156                layouter.namespace(|| format!("share_comm_{i}")),
157                blind,
158                c1x,
159                c2x,
160                c1y,
161                c2y,
162                i,
163            )
164        })
165        .collect::<Result<Vec<_>, _>>()?
166        .try_into()
167        .expect("always 16 elements");
168
169    // Outer hash: shares_hash = Poseidon(share_comm_0, …, share_comm_15)
170    let hasher = PoseidonHash::<
171        pallas::Base,
172        _,
173        poseidon::P128Pow5T3,
174        ConstantLength<16>,
175        3, // WIDTH
176        2, // RATE
177    >::init(
178        poseidon_chip(),
179        layouter.namespace(|| "shares_hash Poseidon init"),
180    )?;
181    hasher.hash(
182        layouter.namespace(|| "shares_hash = Poseidon(share_comms)"),
183        share_comms,
184    )
185}
186
187/// Computes the shares hash in-circuit from pre-computed share commitments:
188///
189/// ```text
190/// shares_hash = Poseidon(share_comm_0, …, share_comm_15)
191/// ```
192///
193/// Unlike [`compute_shares_hash_in_circuit`], this skips the per-share
194/// blind hashing (level 1) because the caller already provides the 16
195/// `share_comm` values — e.g. as private witness cells in ZKP #3.
196///
197/// Returns an internal cell; callers must bind it transitively through their
198/// own public commitment path.
199pub(crate) fn compute_shares_hash_from_comms_in_circuit(
200    poseidon_chip: PoseidonChip<pallas::Base, 3, 2>,
201    mut layouter: impl Layouter<pallas::Base>,
202    share_comms: [AssignedCell<pallas::Base, pallas::Base>; 16],
203) -> Result<AssignedCell<pallas::Base, pallas::Base>, plonk::Error> {
204    let hasher = PoseidonHash::<
205        pallas::Base,
206        _,
207        poseidon::P128Pow5T3,
208        ConstantLength<16>,
209        3, // WIDTH
210        2, // RATE
211    >::init(
212        poseidon_chip,
213        layouter.namespace(|| "shares_hash Poseidon init"),
214    )?;
215    hasher.hash(
216        layouter.namespace(|| "shares_hash = Poseidon(share_comms)"),
217        share_comms,
218    )
219}
220
221/// Native counterpart of [`compute_shares_hash_from_comms_in_circuit`].
222///
223/// Computes `Poseidon(share_comm_0, …, share_comm_15)` outside the circuit.
224pub fn shares_hash_from_comms(share_comms: [pallas::Base; 16]) -> pallas::Base {
225    poseidon::Hash::<_, poseidon::P128Pow5T3, ConstantLength<16>, 3, 2>::init().hash(share_comms)
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    use ff::{Field, PrimeField};
233    use halo2_gadgets::poseidon::Pow5Config as PoseidonConfig;
234    use halo2_proofs::{
235        circuit::{floor_planner, Value},
236        dev::MockProver,
237        plonk::{Advice, Column, ConstraintSystem, Fixed, Instance as InstanceColumn},
238    };
239    use rand::rngs::OsRng;
240
241    // ---------------------------------------------------------------
242    // Shared minimal circuit config (Poseidon only, no ECC).
243    // ---------------------------------------------------------------
244
245    #[derive(Clone)]
246    struct TestConfig {
247        primary: Column<InstanceColumn>,
248        advice: Column<Advice>,
249        poseidon_config: PoseidonConfig<pallas::Base, 3, 2>,
250    }
251
252    impl TestConfig {
253        fn configure(meta: &mut ConstraintSystem<pallas::Base>) -> Self {
254            let primary = meta.instance_column();
255            meta.enable_equality(primary);
256
257            // 5 advice columns: [0] general witness, [1..4] Poseidon state.
258            let advices: [Column<Advice>; 5] = core::array::from_fn(|_| meta.advice_column());
259            for col in &advices {
260                meta.enable_equality(*col);
261            }
262
263            let fixed: [Column<Fixed>; 6] = core::array::from_fn(|_| meta.fixed_column());
264            // Dedicated constants column required by Poseidon strict range checks.
265            let constants = meta.fixed_column();
266            meta.enable_constant(constants);
267            let poseidon_config = PoseidonChip::configure::<poseidon::P128Pow5T3>(
268                meta,
269                advices[1..4].try_into().unwrap(),
270                advices[4],
271                fixed[0..3].try_into().unwrap(),
272                fixed[3..6].try_into().unwrap(),
273            );
274
275            TestConfig {
276                primary,
277                advice: advices[0],
278                poseidon_config,
279            }
280        }
281
282        fn poseidon_chip(&self) -> PoseidonChip<pallas::Base, 3, 2> {
283            PoseidonChip::construct(self.poseidon_config.clone())
284        }
285    }
286
287    /// Witnesses a single field element into the advice column.
288    fn witness(
289        mut layouter: impl Layouter<pallas::Base>,
290        col: Column<Advice>,
291        val: Value<pallas::Base>,
292    ) -> Result<AssignedCell<pallas::Base, pallas::Base>, plonk::Error> {
293        layouter.assign_region(
294            || "witness",
295            |mut region| region.assign_advice(|| "val", col, 0, || val),
296        )
297    }
298
299    // ================================================================
300    // hash_share_commitment_in_circuit
301    // ================================================================
302
303    fn base_from_repr(bytes: [u8; 32]) -> pallas::Base {
304        pallas::Base::from_repr(bytes).expect("frozen vector must be canonical")
305    }
306
307    #[test]
308    fn share_commitment_frozen_vector() {
309        let actual = share_commitment(
310            pallas::Base::from(1u64),
311            pallas::Base::from(2u64),
312            pallas::Base::from(3u64),
313            pallas::Base::from(4u64),
314            pallas::Base::from(5u64),
315        );
316
317        assert_eq!(
318            actual,
319            base_from_repr([
320                183, 66, 173, 64, 240, 83, 206, 161, 132, 211, 79, 38, 240, 12, 144, 142, 247, 139,
321                173, 56, 54, 59, 51, 73, 42, 113, 240, 242, 21, 103, 150, 29,
322            ])
323        );
324    }
325
326    #[test]
327    fn shares_hash_frozen_vector() {
328        let blinds = core::array::from_fn(|i| pallas::Base::from((i + 1) as u64));
329        let enc_c1_x = core::array::from_fn(|i| pallas::Base::from((i + 17) as u64));
330        let enc_c2_x = core::array::from_fn(|i| pallas::Base::from((i + 33) as u64));
331        let enc_c1_y = core::array::from_fn(|i| pallas::Base::from((i + 49) as u64));
332        let enc_c2_y = core::array::from_fn(|i| pallas::Base::from((i + 65) as u64));
333
334        assert_eq!(
335            shares_hash(blinds, enc_c1_x, enc_c2_x, enc_c1_y, enc_c2_y),
336            base_from_repr([
337                125, 88, 190, 64, 180, 158, 228, 46, 43, 173, 80, 255, 152, 160, 47, 234, 86, 36,
338                157, 87, 187, 167, 86, 239, 58, 45, 222, 42, 111, 6, 63, 28,
339            ])
340        );
341    }
342
343    /// Minimal circuit: computes `hash_share_commitment_in_circuit` and
344    /// constrains the result to instance row 0.
345    #[derive(Clone, Default)]
346    struct HashShareCommCircuit {
347        blind: pallas::Base,
348        c1_x: pallas::Base,
349        c2_x: pallas::Base,
350        c1_y: pallas::Base,
351        c2_y: pallas::Base,
352    }
353
354    impl plonk::Circuit<pallas::Base> for HashShareCommCircuit {
355        type Config = TestConfig;
356        type FloorPlanner = floor_planner::V1;
357
358        fn without_witnesses(&self) -> Self {
359            Self::default()
360        }
361
362        fn configure(meta: &mut ConstraintSystem<pallas::Base>) -> Self::Config {
363            TestConfig::configure(meta)
364        }
365
366        fn synthesize(
367            &self,
368            config: Self::Config,
369            mut layouter: impl Layouter<pallas::Base>,
370        ) -> Result<(), plonk::Error> {
371            let blind = witness(
372                layouter.namespace(|| "blind"),
373                config.advice,
374                Value::known(self.blind),
375            )?;
376            let c1x = witness(
377                layouter.namespace(|| "c1_x"),
378                config.advice,
379                Value::known(self.c1_x),
380            )?;
381            let c2x = witness(
382                layouter.namespace(|| "c2_x"),
383                config.advice,
384                Value::known(self.c2_x),
385            )?;
386            let c1y = witness(
387                layouter.namespace(|| "c1_y"),
388                config.advice,
389                Value::known(self.c1_y),
390            )?;
391            let c2y = witness(
392                layouter.namespace(|| "c2_y"),
393                config.advice,
394                Value::known(self.c2_y),
395            )?;
396
397            let result = hash_share_commitment_in_circuit(
398                config.poseidon_chip(),
399                layouter.namespace(|| "hash_share_comm"),
400                blind,
401                c1x,
402                c2x,
403                c1y,
404                c2y,
405                0,
406            )?;
407            layouter.constrain_instance(result.cell(), config.primary, 0)
408        }
409    }
410
411    /// In-circuit result matches the native `share_commitment` helper.
412    #[test]
413    fn hash_share_commitment_matches_native() {
414        let mut rng = OsRng;
415        let blind = pallas::Base::random(&mut rng);
416        let c1_x = pallas::Base::random(&mut rng);
417        let c2_x = pallas::Base::random(&mut rng);
418        let c1_y = pallas::Base::random(&mut rng);
419        let c2_y = pallas::Base::random(&mut rng);
420
421        let expected = share_commitment(blind, c1_x, c2_x, c1_y, c2_y);
422        let circuit = HashShareCommCircuit {
423            blind,
424            c1_x,
425            c2_x,
426            c1_y,
427            c2_y,
428        };
429        let prover =
430            MockProver::run(10, &circuit, vec![vec![expected]]).expect("MockProver::run failed");
431        assert_eq!(prover.verify(), Ok(()));
432    }
433
434    /// Swapping c1 and c2 produces a different hash (input order matters).
435    #[test]
436    fn hash_share_commitment_input_order_matters() {
437        let mut rng = OsRng;
438        let blind = pallas::Base::random(&mut rng);
439        let c1_x = pallas::Base::random(&mut rng);
440        let c2_x = pallas::Base::random(&mut rng);
441        let c1_y = pallas::Base::random(&mut rng);
442        let c2_y = pallas::Base::random(&mut rng);
443
444        let wrong = share_commitment(blind, c2_x, c1_x, c2_y, c1_y);
445        let circuit = HashShareCommCircuit {
446            blind,
447            c1_x,
448            c2_x,
449            c1_y,
450            c2_y,
451        };
452        let prover =
453            MockProver::run(10, &circuit, vec![vec![wrong]]).expect("MockProver::run failed");
454        assert!(prover.verify().is_err());
455    }
456
457    /// Negating a y-coordinate (simulating sign-bit flip) changes the hash.
458    #[test]
459    fn hash_share_commitment_y_negate_changes_hash() {
460        let mut rng = OsRng;
461        let blind = pallas::Base::random(&mut rng);
462        let c1_x = pallas::Base::random(&mut rng);
463        let c2_x = pallas::Base::random(&mut rng);
464        let c1_y = pallas::Base::random(&mut rng);
465        let c2_y = pallas::Base::random(&mut rng);
466
467        let correct = share_commitment(blind, c1_x, c2_x, c1_y, c2_y);
468        let negated = share_commitment(blind, c1_x, c2_x, -c1_y, c2_y);
469        assert_ne!(
470            correct, negated,
471            "negating c1_y must change the share commitment"
472        );
473    }
474
475    // ================================================================
476    // compute_shares_hash_in_circuit
477    // ================================================================
478
479    /// Minimal circuit: computes `compute_shares_hash_in_circuit` over 16
480    /// shares and constrains the result to instance row 0.
481    #[derive(Clone)]
482    struct ComputeSharesHashCircuit {
483        blinds: [pallas::Base; 16],
484        enc_c1_x: [pallas::Base; 16],
485        enc_c2_x: [pallas::Base; 16],
486        enc_c1_y: [pallas::Base; 16],
487        enc_c2_y: [pallas::Base; 16],
488    }
489
490    impl Default for ComputeSharesHashCircuit {
491        fn default() -> Self {
492            Self {
493                blinds: [pallas::Base::zero(); 16],
494                enc_c1_x: [pallas::Base::zero(); 16],
495                enc_c2_x: [pallas::Base::zero(); 16],
496                enc_c1_y: [pallas::Base::zero(); 16],
497                enc_c2_y: [pallas::Base::zero(); 16],
498            }
499        }
500    }
501
502    impl plonk::Circuit<pallas::Base> for ComputeSharesHashCircuit {
503        type Config = TestConfig;
504        type FloorPlanner = floor_planner::V1;
505
506        fn without_witnesses(&self) -> Self {
507            Self::default()
508        }
509
510        fn configure(meta: &mut ConstraintSystem<pallas::Base>) -> Self::Config {
511            TestConfig::configure(meta)
512        }
513
514        fn synthesize(
515            &self,
516            config: Self::Config,
517            mut layouter: impl Layouter<pallas::Base>,
518        ) -> Result<(), plonk::Error> {
519            let mut blind_cells = Vec::with_capacity(16);
520            let mut c1x_cells = Vec::with_capacity(16);
521            let mut c2x_cells = Vec::with_capacity(16);
522            let mut c1y_cells = Vec::with_capacity(16);
523            let mut c2y_cells = Vec::with_capacity(16);
524            for i in 0..16 {
525                blind_cells.push(witness(
526                    layouter.namespace(|| format!("blind_{i}")),
527                    config.advice,
528                    Value::known(self.blinds[i]),
529                )?);
530                c1x_cells.push(witness(
531                    layouter.namespace(|| format!("c1x_{i}")),
532                    config.advice,
533                    Value::known(self.enc_c1_x[i]),
534                )?);
535                c2x_cells.push(witness(
536                    layouter.namespace(|| format!("c2x_{i}")),
537                    config.advice,
538                    Value::known(self.enc_c2_x[i]),
539                )?);
540                c1y_cells.push(witness(
541                    layouter.namespace(|| format!("c1y_{i}")),
542                    config.advice,
543                    Value::known(self.enc_c1_y[i]),
544                )?);
545                c2y_cells.push(witness(
546                    layouter.namespace(|| format!("c2y_{i}")),
547                    config.advice,
548                    Value::known(self.enc_c2_y[i]),
549                )?);
550            }
551            let blinds: [AssignedCell<pallas::Base, pallas::Base>; 16] =
552                blind_cells.try_into().unwrap();
553            let enc_c1_x: [AssignedCell<pallas::Base, pallas::Base>; 16] =
554                c1x_cells.try_into().unwrap();
555            let enc_c2_x: [AssignedCell<pallas::Base, pallas::Base>; 16] =
556                c2x_cells.try_into().unwrap();
557            let enc_c1_y: [AssignedCell<pallas::Base, pallas::Base>; 16] =
558                c1y_cells.try_into().unwrap();
559            let enc_c2_y: [AssignedCell<pallas::Base, pallas::Base>; 16] =
560                c2y_cells.try_into().unwrap();
561
562            let result = compute_shares_hash_in_circuit(
563                || config.poseidon_chip(),
564                layouter.namespace(|| "compute_shares_hash"),
565                blinds,
566                enc_c1_x,
567                enc_c2_x,
568                enc_c1_y,
569                enc_c2_y,
570            )?;
571            layouter.constrain_instance(result.cell(), config.primary, 0)
572        }
573    }
574
575    /// In-circuit result matches the native `shares_hash` helper.
576    #[test]
577    fn compute_shares_hash_matches_native() {
578        let mut rng = OsRng;
579        let blinds: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
580        let enc_c1_x: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
581        let enc_c2_x: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
582        let enc_c1_y: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
583        let enc_c2_y: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
584
585        let expected = shares_hash(blinds, enc_c1_x, enc_c2_x, enc_c1_y, enc_c2_y);
586        let circuit = ComputeSharesHashCircuit {
587            blinds,
588            enc_c1_x,
589            enc_c2_x,
590            enc_c1_y,
591            enc_c2_y,
592        };
593        // K=12 (4096 rows) comfortably fits 17 chained Poseidon(5) regions.
594        let prover =
595            MockProver::run(12, &circuit, vec![vec![expected]]).expect("MockProver::run failed");
596        assert_eq!(prover.verify(), Ok(()));
597    }
598
599    /// Corrupting any single enc_c1_x value changes the output.
600    #[test]
601    fn compute_shares_hash_wrong_enc_c1_fails() {
602        let mut rng = OsRng;
603        let blinds: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
604        let enc_c1_x: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
605        let enc_c2_x: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
606        let enc_c1_y: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
607        let enc_c2_y: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
608
609        let correct = shares_hash(blinds, enc_c1_x, enc_c2_x, enc_c1_y, enc_c2_y);
610
611        let mut circuit = ComputeSharesHashCircuit {
612            blinds,
613            enc_c1_x,
614            enc_c2_x,
615            enc_c1_y,
616            enc_c2_y,
617        };
618        circuit.enc_c1_x[2] = pallas::Base::random(&mut rng);
619
620        let prover =
621            MockProver::run(12, &circuit, vec![vec![correct]]).expect("MockProver::run failed");
622        assert!(prover.verify().is_err());
623    }
624
625    /// Every one of the 16 share positions contributes to the native output hash.
626    ///
627    /// `compute_shares_hash_matches_native` covers the in-circuit/native
628    /// equivalence once; this test keeps the per-position coverage without
629    /// running a separate K=12 prover for every position.
630    #[test]
631    fn all_16_share_positions_are_hashed() {
632        let mut rng = OsRng;
633        let blinds: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
634        let enc_c1_x: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
635        let enc_c2_x: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
636        let enc_c1_y: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
637        let enc_c2_y: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
638
639        let correct = shares_hash(blinds, enc_c1_x, enc_c2_x, enc_c1_y, enc_c2_y);
640
641        for i in 0..16 {
642            let mut perturbed_enc_c1_x = enc_c1_x;
643            perturbed_enc_c1_x[i] += pallas::Base::one();
644
645            assert_ne!(
646                shares_hash(blinds, perturbed_enc_c1_x, enc_c2_x, enc_c1_y, enc_c2_y),
647                correct,
648                "perturbing enc_c1_x[{i}] did not change the shares_hash"
649            );
650        }
651    }
652
653    /// Corrupting a blind factor changes the output.
654    #[test]
655    fn compute_shares_hash_wrong_blind_fails() {
656        let mut rng = OsRng;
657        let blinds: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
658        let enc_c1_x: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
659        let enc_c2_x: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
660        let enc_c1_y: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
661        let enc_c2_y: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
662
663        let correct = shares_hash(blinds, enc_c1_x, enc_c2_x, enc_c1_y, enc_c2_y);
664
665        let mut circuit = ComputeSharesHashCircuit {
666            blinds,
667            enc_c1_x,
668            enc_c2_x,
669            enc_c1_y,
670            enc_c2_y,
671        };
672        circuit.blinds[0] = pallas::Base::random(&mut rng);
673
674        let prover =
675            MockProver::run(12, &circuit, vec![vec![correct]]).expect("MockProver::run failed");
676        assert!(prover.verify().is_err());
677    }
678
679    // ================================================================
680    // compute_shares_hash_from_comms_in_circuit
681    // ================================================================
682
683    /// Minimal circuit: computes `compute_shares_hash_from_comms_in_circuit`
684    /// from 16 pre-computed share_comms and constrains to instance row 0.
685    #[derive(Clone)]
686    struct ComputeSharesHashFromCommsCircuit {
687        share_comms: [pallas::Base; 16],
688    }
689
690    impl Default for ComputeSharesHashFromCommsCircuit {
691        fn default() -> Self {
692            Self {
693                share_comms: [pallas::Base::zero(); 16],
694            }
695        }
696    }
697
698    impl plonk::Circuit<pallas::Base> for ComputeSharesHashFromCommsCircuit {
699        type Config = TestConfig;
700        type FloorPlanner = floor_planner::V1;
701
702        fn without_witnesses(&self) -> Self {
703            Self::default()
704        }
705
706        fn configure(meta: &mut ConstraintSystem<pallas::Base>) -> Self::Config {
707            TestConfig::configure(meta)
708        }
709
710        fn synthesize(
711            &self,
712            config: Self::Config,
713            mut layouter: impl Layouter<pallas::Base>,
714        ) -> Result<(), plonk::Error> {
715            let mut comm_cells = Vec::with_capacity(16);
716            for i in 0..16 {
717                comm_cells.push(witness(
718                    layouter.namespace(|| format!("comm_{i}")),
719                    config.advice,
720                    Value::known(self.share_comms[i]),
721                )?);
722            }
723            let comms: [AssignedCell<pallas::Base, pallas::Base>; 16] =
724                comm_cells.try_into().unwrap();
725
726            let result = super::compute_shares_hash_from_comms_in_circuit(
727                config.poseidon_chip(),
728                layouter.namespace(|| "hash_from_comms"),
729                comms,
730            )?;
731            layouter.constrain_instance(result.cell(), config.primary, 0)
732        }
733    }
734
735    /// Minimal circuit: computes both in-circuit `shares_hash` paths from the
736    /// same witness and constrains their outputs equal without a native oracle.
737    #[derive(Clone)]
738    struct SharesHashInCircuitEquivalenceCircuit {
739        blinds: [pallas::Base; 16],
740        enc_c1_x: [pallas::Base; 16],
741        enc_c2_x: [pallas::Base; 16],
742        enc_c1_y: [pallas::Base; 16],
743        enc_c2_y: [pallas::Base; 16],
744    }
745
746    impl Default for SharesHashInCircuitEquivalenceCircuit {
747        fn default() -> Self {
748            Self {
749                blinds: [pallas::Base::zero(); 16],
750                enc_c1_x: [pallas::Base::zero(); 16],
751                enc_c2_x: [pallas::Base::zero(); 16],
752                enc_c1_y: [pallas::Base::zero(); 16],
753                enc_c2_y: [pallas::Base::zero(); 16],
754            }
755        }
756    }
757
758    impl plonk::Circuit<pallas::Base> for SharesHashInCircuitEquivalenceCircuit {
759        type Config = TestConfig;
760        type FloorPlanner = floor_planner::V1;
761
762        fn without_witnesses(&self) -> Self {
763            Self::default()
764        }
765
766        fn configure(meta: &mut ConstraintSystem<pallas::Base>) -> Self::Config {
767            TestConfig::configure(meta)
768        }
769
770        fn synthesize(
771            &self,
772            config: Self::Config,
773            mut layouter: impl Layouter<pallas::Base>,
774        ) -> Result<(), plonk::Error> {
775            let mut blind_cells = Vec::with_capacity(16);
776            let mut c1x_cells = Vec::with_capacity(16);
777            let mut c2x_cells = Vec::with_capacity(16);
778            let mut c1y_cells = Vec::with_capacity(16);
779            let mut c2y_cells = Vec::with_capacity(16);
780            for i in 0..16 {
781                blind_cells.push(witness(
782                    layouter.namespace(|| format!("blind_{i}")),
783                    config.advice,
784                    Value::known(self.blinds[i]),
785                )?);
786                c1x_cells.push(witness(
787                    layouter.namespace(|| format!("c1x_{i}")),
788                    config.advice,
789                    Value::known(self.enc_c1_x[i]),
790                )?);
791                c2x_cells.push(witness(
792                    layouter.namespace(|| format!("c2x_{i}")),
793                    config.advice,
794                    Value::known(self.enc_c2_x[i]),
795                )?);
796                c1y_cells.push(witness(
797                    layouter.namespace(|| format!("c1y_{i}")),
798                    config.advice,
799                    Value::known(self.enc_c1_y[i]),
800                )?);
801                c2y_cells.push(witness(
802                    layouter.namespace(|| format!("c2y_{i}")),
803                    config.advice,
804                    Value::known(self.enc_c2_y[i]),
805                )?);
806            }
807
808            let blinds_full: [AssignedCell<pallas::Base, pallas::Base>; 16] =
809                core::array::from_fn(|i| blind_cells[i].clone());
810            let enc_c1_x_full: [AssignedCell<pallas::Base, pallas::Base>; 16] =
811                core::array::from_fn(|i| c1x_cells[i].clone());
812            let enc_c2_x_full: [AssignedCell<pallas::Base, pallas::Base>; 16] =
813                core::array::from_fn(|i| c2x_cells[i].clone());
814            let enc_c1_y_full: [AssignedCell<pallas::Base, pallas::Base>; 16] =
815                core::array::from_fn(|i| c1y_cells[i].clone());
816            let enc_c2_y_full: [AssignedCell<pallas::Base, pallas::Base>; 16] =
817                core::array::from_fn(|i| c2y_cells[i].clone());
818
819            let full_hash = compute_shares_hash_in_circuit(
820                || config.poseidon_chip(),
821                layouter.namespace(|| "full shares_hash path"),
822                blinds_full,
823                enc_c1_x_full,
824                enc_c2_x_full,
825                enc_c1_y_full,
826                enc_c2_y_full,
827            )?;
828
829            let share_comms: [AssignedCell<pallas::Base, pallas::Base>; 16] = (0..16)
830                .map(|i| {
831                    hash_share_commitment_in_circuit(
832                        config.poseidon_chip(),
833                        layouter.namespace(|| format!("from-comms share_comm_{i}")),
834                        blind_cells[i].clone(),
835                        c1x_cells[i].clone(),
836                        c2x_cells[i].clone(),
837                        c1y_cells[i].clone(),
838                        c2y_cells[i].clone(),
839                        i,
840                    )
841                })
842                .collect::<Result<Vec<_>, _>>()?
843                .try_into()
844                .expect("always 16 elements");
845
846            let from_comms_hash = super::compute_shares_hash_from_comms_in_circuit(
847                config.poseidon_chip(),
848                layouter.namespace(|| "from-comms shares_hash path"),
849                share_comms,
850            )?;
851
852            layouter.assign_region(
853                || "full shares_hash == from-comms shares_hash",
854                |mut region| region.constrain_equal(full_hash.cell(), from_comms_hash.cell()),
855            )
856        }
857    }
858
859    /// The from-comms gadget matches the two-level native computation.
860    #[test]
861    fn shares_hash_from_comms_matches_native() {
862        let mut rng = OsRng;
863        let blinds: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
864        let enc_c1_x: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
865        let enc_c2_x: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
866        let enc_c1_y: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
867        let enc_c2_y: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
868
869        let comms: [pallas::Base; 16] = core::array::from_fn(|i| {
870            share_commitment(
871                blinds[i],
872                enc_c1_x[i],
873                enc_c2_x[i],
874                enc_c1_y[i],
875                enc_c2_y[i],
876            )
877        });
878        let expected = super::shares_hash_from_comms(comms);
879
880        assert_eq!(
881            expected,
882            shares_hash(blinds, enc_c1_x, enc_c2_x, enc_c1_y, enc_c2_y)
883        );
884
885        let circuit = ComputeSharesHashFromCommsCircuit { share_comms: comms };
886        let prover =
887            MockProver::run(12, &circuit, vec![vec![expected]]).expect("MockProver::run failed");
888        assert_eq!(prover.verify(), Ok(()));
889    }
890
891    /// The full two-level in-circuit path and the from-comms in-circuit path
892    /// agree on the same witnesses without relying on a native expected value.
893    #[test]
894    fn compute_shares_hash_in_circuit_matches_from_comms_in_circuit() {
895        let mut rng = OsRng;
896        let circuit = SharesHashInCircuitEquivalenceCircuit {
897            blinds: core::array::from_fn(|_| pallas::Base::random(&mut rng)),
898            enc_c1_x: core::array::from_fn(|_| pallas::Base::random(&mut rng)),
899            enc_c2_x: core::array::from_fn(|_| pallas::Base::random(&mut rng)),
900            enc_c1_y: core::array::from_fn(|_| pallas::Base::random(&mut rng)),
901            enc_c2_y: core::array::from_fn(|_| pallas::Base::random(&mut rng)),
902        };
903
904        // K=13 fits the two complete 17-Poseidon paths used by this equivalence test.
905        let prover = MockProver::run(13, &circuit, vec![vec![]]).expect("MockProver::run failed");
906        assert_eq!(prover.verify(), Ok(()));
907    }
908
909    /// Corrupting any single share_comm changes the output.
910    #[test]
911    fn shares_hash_from_comms_wrong_comm_fails() {
912        let mut rng = OsRng;
913        let comms: [pallas::Base; 16] = core::array::from_fn(|_| pallas::Base::random(&mut rng));
914        let expected = super::shares_hash_from_comms(comms);
915
916        let mut bad_comms = comms;
917        bad_comms[7] = pallas::Base::random(&mut rng);
918        let circuit = ComputeSharesHashFromCommsCircuit {
919            share_comms: bad_comms,
920        };
921        let prover =
922            MockProver::run(12, &circuit, vec![vec![expected]]).expect("MockProver::run failed");
923        assert!(prover.verify().is_err());
924    }
925}