Skip to main content

voting_circuits/share_reveal/
circuit.rs

1//! The Share Reveal circuit implementation (ZKP #3).
2//!
3//! Proves that a publicly-revealed encrypted share came from a valid,
4//! registered vote commitment — without revealing which one. The circuit
5//! verifies 5 conditions:
6//!
7//! - **Condition 1**: VC Membership — Poseidon Merkle path from `vote_commitment`
8//!   to `vote_comm_tree_root`.
9//! - **Condition 2**: Vote Commitment Integrity — `vote_commitment =
10//!   Poseidon(DOMAIN_VC, voting_round_id, shares_hash, proposal_id, vote_decision)`.
11//! - **Condition 3**: Shares Hash Integrity — `shares_hash =
12//!   Poseidon(share_comm_0, ..., share_comm_15)`, where share_comms are
13//!   private witnesses transitively bound to the public tree root.
14//! - **Condition 4**: Primary Share Binding — the voting client knows a
15//!   blind such that
16//!   `share_comms[share_index] = Poseidon(DOMAIN_SHARE_COMM, blind, c1_x, c2_x, c1_y, c2_y)`
17//!   (see `crate::circuit::share_commitment` for the authoritative shape;
18//!   the domain tag separates this from vote commitments, and the
19//!   y-coordinates defend against ciphertext sign-malleability),
20//!   binding the publicly revealed encrypted share to the committed set.
21//! - **Condition 5**: Share Nullifier Integrity — `share_nullifier` is
22//!   correctly derived as
23//!   `Poseidon(domain_tag, vote_commitment, share_index, blind)`.
24//!   `blind` is the share commitment blinding factor — a secret held by
25//!   the voting client (the host program that built ZKP #2 and now
26//!   builds this reveal proof). Using the blind (rather than a
27//!   ciphertext coordinate) ensures the nullifier is not publicly
28//!   derivable from on-chain data, since ciphertext coordinates are
29//!   posted as public inputs alongside the proof. Round, proposal, decision,
30//!   and `shares_hash` bind through the `vote_commitment` preimage;
31//!   `share_comms` bind one hop earlier through `shares_hash`. The resulting
32//!   `vote_commitment` is checked against the vote commitment tree.
33//!
34//! ## Privacy
35//!
36//! Only the primary share's blind is supplied as a private witness, so
37//! the voting client does not need to surface the other 15 blinds when
38//! it assembles the reveal. The 16 `share_comms` are private witnesses —
39//! they never appear on chain, preserving share-level unlinkability.
40//! Soundness is guaranteed because `share_comms` are transitively bound
41//! to the public `vote_comm_tree_root` via
42//! `shares_hash → vote_commitment → Merkle path`; the revealed ciphertext
43//! coordinates bind to the selected `share_comm` through Poseidon preimage
44//! resistance of `Poseidon(blind, c1_x, c2_x, c1_y, c2_y)`.
45//!
46//! Authoritative hash sources: `crate::circuit::share_commitment` owns the
47//! per-share encrypted-share preimage, `crate::shares_hash` owns the aggregate
48//! `shares_hash` preimage, `crate::circuit::vote_commitment` owns the vote
49//! commitment preimage, and `crate::domain_tags` owns domain tag encoding.
50//! This module's prose points to those owners rather than defining competing
51//! formulas.
52//!
53//! ## Column layout
54//!
55//! - 9 advice columns: advices\[0..4\] general + Merkle swap, \[5\] Poseidon partial
56//!   S-box, \[6..8\] Poseidon state.
57//! - 8 fixed columns for Poseidon round constants + constants.
58//! - 1 instance column (9 public inputs).
59//! - K = 11 (2,048 rows).
60
61use std::vec::Vec;
62
63use halo2_proofs::{
64    circuit::{floor_planner, AssignedCell, Layouter, Value},
65    plonk::{
66        self, Advice, Column, ConstraintSystem, Constraints, Expression, Fixed,
67        Instance as InstanceColumn, Selector,
68    },
69    poly::Rotation,
70};
71use itertools::Itertools;
72use pasta_curves::{pallas, vesta};
73
74use halo2_gadgets::{
75    poseidon::{
76        primitives::{self as poseidon, ConstantLength},
77        Hash as PoseidonHash, Pow5Chip as PoseidonChip, Pow5Config as PoseidonConfig,
78    },
79    utilities::bool_check,
80};
81
82use orchard::circuit::gadget::assign_free_advice;
83
84use crate::circuit::poseidon_merkle::{synthesize_poseidon_merkle_path, MerkleSwapGate};
85use crate::circuit::share_commitment;
86use crate::circuit::vote_commitment;
87use crate::shares_hash::{
88    compute_shares_hash_from_comms_in_circuit, hash_share_commitment_in_circuit,
89};
90use crate::vote_proof::VOTE_COMM_TREE_DEPTH;
91
92// ================================================================
93// Constants
94// ================================================================
95
96/// Circuit size (2^K rows).
97///
98/// K=11 (2,048 rows). `CircuitCost::measure` reports a floor-planner
99/// high-water mark of 1,632 rows (79.7% of 2,048). The `V1` floor
100/// planner packs non-overlapping regions into the same row range across
101/// different columns.
102///
103/// Run the `row_budget` test to re-measure after circuit changes:
104///   `cargo test row_budget -- --nocapture --ignored`
105pub const K: u32 = 11;
106
107// ================================================================
108// Public input offsets (9 field elements).
109// ================================================================
110
111/// Public input offset for the share nullifier (prevents double-counting).
112pub const SHARE_NULLIFIER_PUBLIC_OFFSET: usize = 0;
113/// Public input offset for the revealed share's C1 x-coordinate.
114///
115/// This is caller-supplied. Condition 4 binds it transitively to the committed
116/// vote by proving `Poseidon(blind, c1_x, c2_x, c1_y, c2_y)` equals the
117/// selected private `share_comm`; ZKP #2 does not publish per-share
118/// ciphertext coordinates as public inputs.
119pub const ENC_SHARE_C1_X_PUBLIC_OFFSET: usize = 1;
120/// Public input offset for the revealed share's C1 y-coordinate.
121///
122/// Binds the proof to the exact curve point (not just x-coordinate),
123/// preventing ciphertext sign-malleability attacks where an adversary
124/// negates ElGamal ciphertext points without invalidating the ZKP. Like the
125/// x-coordinate, this is caller-supplied and bound through the selected
126/// `share_comm` Poseidon preimage.
127pub const ENC_SHARE_C1_Y_PUBLIC_OFFSET: usize = 2;
128/// Public input offset for the revealed share's C2 x-coordinate.
129///
130/// Caller-supplied and bound through condition 4's selected share-commitment
131/// equality; not directly published by ZKP #2.
132pub const ENC_SHARE_C2_X_PUBLIC_OFFSET: usize = 3;
133/// Public input offset for the revealed share's C2 y-coordinate.
134///
135/// Caller-supplied y-coordinate for exact-point binding, transitively tied to
136/// the committed vote through the selected `share_comm`.
137pub const ENC_SHARE_C2_Y_PUBLIC_OFFSET: usize = 4;
138/// Public input offset for the proposal identifier.
139pub const PROPOSAL_ID_PUBLIC_OFFSET: usize = 5;
140/// Public input offset for the vote decision.
141pub const VOTE_DECISION_PUBLIC_OFFSET: usize = 6;
142/// Public input offset for the vote commitment tree root.
143pub const VOTE_COMM_TREE_ROOT_PUBLIC_OFFSET: usize = 7;
144/// Public input offset for the voting round identifier.
145///
146/// Constrained in-circuit: `voting_round_id` is hashed into `vote_commitment`
147/// and `vote_commitment` is hashed into the share nullifier. That transitive
148/// path binds the nullifier to a specific round. This prevents cross-round
149/// proof replay because the commitment tree is global, not per-round, so
150/// `vote_comm_tree_root` alone does not provide round scoping. The chain also
151/// validates that `voting_round_id` matches an active session (Gov Steps V1
152/// §5.4 "Out-of-circuit checks").
153pub const VOTING_ROUND_ID_PUBLIC_OFFSET: usize = 8;
154
155// ================================================================
156// Out-of-circuit helpers
157// ================================================================
158
159/// Domain separator for share nullifiers, encoded as a Pallas base field element.
160///
161/// `"share spend"` → 32-byte zero-padded array → `Fp::from_repr`.
162pub use crate::domain_tags::share_spend as domain_tag_share_spend;
163
164/// Out-of-circuit share nullifier hash (condition 5).
165///
166/// ```text
167/// share_nullifier = Poseidon(domain_tag, vote_commitment, share_index, blind)
168/// ```
169///
170/// Single `ConstantLength<4>` call (2 permutations at rate=2).
171/// `blind` is the share commitment blinding factor for this share index.
172/// Because blinds are never posted on-chain, the nullifier cannot be
173/// derived by an observer — even one who knows the vote commitment tree
174/// contents and the public ciphertext coordinates. Round, proposal, decision,
175/// and `shares_hash` bind through the `vote_commitment` preimage;
176/// `share_comms` bind one hop earlier through `shares_hash`. The nullifier
177/// deliberately consumes the parent vote commitment instead of re-hashing its
178/// full preimage.
179pub fn share_nullifier_hash(
180    vote_commitment: pallas::Base,
181    share_index: pallas::Base,
182    blind: pallas::Base,
183) -> pallas::Base {
184    poseidon::Hash::<_, poseidon::P128Pow5T3, ConstantLength<4>, 3, 2>::init().hash([
185        domain_tag_share_spend(),
186        vote_commitment,
187        share_index,
188        blind,
189    ])
190}
191
192// ================================================================
193// Config
194// ================================================================
195
196/// Configuration for the Share Reveal circuit.
197///
198/// Holds the Poseidon chip config, the Merkle swap gate selector,
199/// and the share commitment multiplexer gate selector.
200#[derive(Clone, Debug)]
201pub struct Config {
202    /// Public input column (9 field elements).
203    primary: Column<InstanceColumn>,
204    /// 9 advice columns for private witness data.
205    advices: [Column<Advice>; 9],
206    /// Poseidon hash chip configuration.
207    poseidon_config: PoseidonConfig<pallas::Base, 3, 2>,
208    /// Merkle conditional swap gate (condition 1).
209    merkle_swap: MerkleSwapGate,
210    /// Selector for the share commitment multiplexer gate (condition 4).
211    ///
212    /// Fires on a 4-row block (9 advice columns, Rotation 0..3):
213    ///   Row 0: sel_0..sel_8     (advices[0..9])
214    ///   Row 1: sel_9..sel_15    (advices[0..7]),  comm_0..comm_1  (advices[7..9])
215    ///   Row 2: comm_2..comm_10  (advices[0..9])
216    ///   Row 3: comm_11..comm_15 (advices[0..5]),  selected_comm   (advices[5]),
217    ///          share_index      (advices[6])
218    ///
219    /// Constraints:
220    /// - Each sel_i is boolean.
221    /// - Exactly one sel_i is 1.
222    /// - share_index == Σ i * sel_i (index reconstruction, replaces 16 per-bit checks).
223    /// - selected_comm = Σ sel_i * comm_i.
224    q_share_comm_mux: Selector,
225}
226
227impl Config {
228    /// Constructs a Poseidon chip from this configuration.
229    pub(crate) fn poseidon_chip(&self) -> PoseidonChip<pallas::Base, 3, 2> {
230        PoseidonChip::construct(self.poseidon_config.clone())
231    }
232
233    /// Assigns a field-element constant to an advice cell so the value is
234    /// baked into the verification key via `assign_advice_from_constant`.
235    pub(crate) fn assign_constant(
236        &self,
237        layouter: &mut impl Layouter<pallas::Base>,
238        label: &'static str,
239        value: pallas::Base,
240    ) -> Result<AssignedCell<pallas::Base, pallas::Base>, plonk::Error> {
241        layouter.assign_region(
242            || label,
243            |mut region| region.assign_advice_from_constant(|| label, self.advices[0], 0, value),
244        )
245    }
246}
247
248// ================================================================
249// Circuit
250// ================================================================
251
252/// The Share Reveal circuit (ZKP #3).
253///
254/// Proves that a publicly-revealed encrypted share came from a valid,
255/// registered vote commitment — without revealing which one.
256#[derive(Clone, Debug)]
257pub struct Circuit {
258    // === Condition 1: VC Membership ===
259    /// Merkle authentication path (sibling hashes at each tree level).
260    pub(crate) vote_comm_tree_path: Value<[pallas::Base; VOTE_COMM_TREE_DEPTH]>,
261    /// Leaf position in the vote commitment tree.
262    pub(crate) vote_comm_tree_position: Value<u32>,
263
264    // === Condition 3: Shares Hash Integrity ===
265    /// Pre-computed per-share Poseidon commitments (private witnesses).
266    ///
267    /// Shape: see `crate::circuit::share_commitment`:
268    /// `Poseidon(DOMAIN_SHARE_COMM, blind, c1_x, c2_x, c1_y, c2_y)`.
269    /// The y-coordinates defend against ciphertext sign-malleability.
270    /// Transitively bound to the public tree root via
271    /// `shares_hash → vote_commitment → Merkle path`.
272    pub(crate) share_comms: [Value<pallas::Base>; 16],
273
274    // === Condition 4: Primary Share Binding ===
275    /// Blind factor for the revealed share. The synthesize body
276    /// (see the "Condition 4: Primary Share Binding" region below) recomputes
277    /// `Poseidon(DOMAIN_SHARE_COMM, primary_blind, c1_x, c2_x, c1_y, c2_y)`
278    /// using the shared `crate::shares_hash` gadget and constrains it to equal
279    /// `share_comms[share_index]`; the y-coordinates are the
280    /// sign-malleability defense and the gadget is the single source of
281    /// truth for the preimage shape.
282    pub(crate) primary_blind: Value<pallas::Base>,
283
284    // === Share selection ===
285    /// Which of the 16 shares is being revealed (0..15).
286    pub(crate) share_index: Value<pallas::Base>,
287
288    // === Condition 5: Share Nullifier Integrity ===
289    /// The vote commitment leaf value (links conditions 1, 2, and 5).
290    pub(crate) vote_commitment: Value<pallas::Base>,
291}
292
293impl Default for Circuit {
294    fn default() -> Self {
295        Self {
296            vote_comm_tree_path: Value::unknown(),
297            vote_comm_tree_position: Value::unknown(),
298            share_comms: [Value::unknown(); 16],
299            primary_blind: Value::unknown(),
300            share_index: Value::unknown(),
301            vote_commitment: Value::unknown(),
302        }
303    }
304}
305
306impl plonk::Circuit<pallas::Base> for Circuit {
307    type Config = Config;
308    type FloorPlanner = floor_planner::V1;
309
310    fn without_witnesses(&self) -> Self {
311        Self::default()
312    }
313
314    fn configure(meta: &mut ConstraintSystem<pallas::Base>) -> Self::Config {
315        // 9 advice columns — the minimum required by the three gadgets in this circuit:
316        //   [0..4]  Merkle conditional swap gate (pos_bit, current, sibling, left, right).
317        //   [5]     Poseidon Pow5T3 partial S-box column (internal to the chip).
318        //   [6..8]  Poseidon width-3 state columns.
319        // The share commitment mux gate (condition 4) reuses all 9 columns across
320        // 4 rows to pack its 16 one-hot selectors + 16 commitments without needing
321        // an additional column.
322        let advices: [Column<Advice>; 9] = core::array::from_fn(|_| meta.advice_column());
323        for col in &advices {
324            meta.enable_equality(*col);
325        }
326
327        // Instance column for public inputs.
328        let primary = meta.instance_column();
329        meta.enable_equality(primary);
330
331        // 8 fixed columns shared between Poseidon round constants and
332        // general constants.
333        let lagrange_coeffs: [Column<Fixed>; 8] = core::array::from_fn(|_| meta.fixed_column());
334        let rc_a = lagrange_coeffs[2..5].try_into().unwrap();
335        let rc_b = lagrange_coeffs[5..8].try_into().unwrap();
336
337        // Enable constants via the first fixed column.
338        meta.enable_constant(lagrange_coeffs[0]);
339
340        // Poseidon chip: P128Pow5T3 with width 3, rate 2.
341        // State columns: advices[6..8], partial S-box: advices[5].
342        let poseidon_config = PoseidonChip::configure::<poseidon::P128Pow5T3>(
343            meta,
344            advices[6..9].try_into().unwrap(),
345            advices[5],
346            rc_a,
347            rc_b,
348        );
349
350        // Merkle conditional swap gate (condition 1).
351        let merkle_swap = MerkleSwapGate::configure(
352            meta,
353            [advices[0], advices[1], advices[2], advices[3], advices[4]],
354        );
355
356        // Share commitment multiplexer gate (condition 4).
357        // Col →  [0]       [1]       [2]        [3]        [4]        [5]        [6]       [7]       [8]
358        // ------+---------+---------+----------+----------+----------+----------+---------+---------+---------
359        // Row 0 | sel[0]  | sel[1]  | sel[2]   | sel[3]   | sel[4]   | sel[5]   | sel[6]  | sel[7]  | sel[8]
360        // Row 1 | sel[9]  | sel[10] | sel[11]  | sel[12]  | sel[13]  | sel[14]  | sel[15] | comm[0] | comm[1]
361        // Row 2 | comm[2] | comm[3] | comm[4]  | comm[5]  | comm[6]  | comm[7]  | comm[8] | comm[9] |comm[10]
362        // Row 3 | comm[11]| comm[12]| comm[13] | comm[14] | comm[15] | sel_comm | share_idx| —      | —
363        let q_share_comm_mux = meta.selector();
364        meta.create_gate("share commitment multiplexer", |meta| {
365            let q = meta.query_selector(q_share_comm_mux);
366
367            let sel: [_; 16] = [
368                meta.query_advice(advices[0], Rotation::cur()),
369                meta.query_advice(advices[1], Rotation::cur()),
370                meta.query_advice(advices[2], Rotation::cur()),
371                meta.query_advice(advices[3], Rotation::cur()),
372                meta.query_advice(advices[4], Rotation::cur()),
373                meta.query_advice(advices[5], Rotation::cur()),
374                meta.query_advice(advices[6], Rotation::cur()),
375                meta.query_advice(advices[7], Rotation::cur()),
376                meta.query_advice(advices[8], Rotation::cur()),
377                meta.query_advice(advices[0], Rotation::next()),
378                meta.query_advice(advices[1], Rotation::next()),
379                meta.query_advice(advices[2], Rotation::next()),
380                meta.query_advice(advices[3], Rotation::next()),
381                meta.query_advice(advices[4], Rotation::next()),
382                meta.query_advice(advices[5], Rotation::next()),
383                meta.query_advice(advices[6], Rotation::next()),
384            ];
385
386            let comm: [_; 16] = [
387                meta.query_advice(advices[7], Rotation::next()),
388                meta.query_advice(advices[8], Rotation::next()),
389                meta.query_advice(advices[0], Rotation(2)),
390                meta.query_advice(advices[1], Rotation(2)),
391                meta.query_advice(advices[2], Rotation(2)),
392                meta.query_advice(advices[3], Rotation(2)),
393                meta.query_advice(advices[4], Rotation(2)),
394                meta.query_advice(advices[5], Rotation(2)),
395                meta.query_advice(advices[6], Rotation(2)),
396                meta.query_advice(advices[7], Rotation(2)),
397                meta.query_advice(advices[8], Rotation(2)),
398                meta.query_advice(advices[0], Rotation(3)),
399                meta.query_advice(advices[1], Rotation(3)),
400                meta.query_advice(advices[2], Rotation(3)),
401                meta.query_advice(advices[3], Rotation(3)),
402                meta.query_advice(advices[4], Rotation(3)),
403            ];
404
405            let selected_comm = meta.query_advice(advices[5], Rotation(3));
406            let share_index = meta.query_advice(advices[6], Rotation(3));
407
408            let one = Expression::Constant(pallas::Base::one());
409
410            // Boolean checks for all 16 selection bits.
411            let bool_checks: Vec<(&'static str, Expression<pallas::Base>)> = (0..16)
412                .map(|i| ("bool sel_i", bool_check(sel[i].clone())))
413                .collect();
414
415            // Sum check for selectors (only one is 1)
416            let sum_expr = sel
417                .iter()
418                .skip(1)
419                .fold(sel[0].clone(), |acc, s| acc + s.clone());
420            let sum_check = ("sum sel == 1", sum_expr - one);
421
422            // Index reconstruction: share_index == sum(i * sel[i]).
423            //
424            // Given bool + sum guarantees exactly one sel[j] = 1, the sum collapses
425            // to j.
426            let reconstructed = sel
427                .iter()
428                .enumerate()
429                .skip(1)
430                .fold(Expression::Constant(pallas::Base::zero()), |acc, (i, s)| {
431                    acc + Expression::Constant(pallas::Base::from(i as u64)) * s.clone()
432                });
433            let index_reconstruct = ("index reconstruct", share_index.clone() - reconstructed);
434
435            // Selected commitment must equal the dot product:
436            // selected_comm == Σ sel[i] * comm[i]
437            let comm_mux_expr = comm
438                .iter()
439                .zip_eq(sel.iter())
440                .fold(selected_comm, |acc, (c, s)| acc - s.clone() * c.clone());
441            let comm_mux = ("comm mux", comm_mux_expr);
442
443            // What these four groups together guarantee:
444            // The bool + sum constraints establish one-hotness.
445            // Given one-hotness, the index reconstruction collapses to share_index == j where j is the unique set position.
446            // The mux constraint then collapses to selected_comm == comm[j].
447            // Combined with the constrain_equal(derived_comm, selected_comm), the full chain is:
448            // derived_comm  ==  comm[share_index]  ==  share_comms[share_index]
449            // The last equality is enforced by copy_advice.
450            let mut constraints: Vec<(&'static str, Expression<pallas::Base>)> = bool_checks;
451            constraints.push(sum_check);
452            constraints.push(index_reconstruct);
453            constraints.push(comm_mux);
454
455            Constraints::with_selector(q, constraints)
456        });
457
458        Config {
459            primary,
460            advices,
461            poseidon_config,
462            merkle_swap,
463            q_share_comm_mux,
464        }
465    }
466
467    #[allow(non_snake_case)]
468    fn synthesize(
469        &self,
470        config: Self::Config,
471        mut layouter: impl Layouter<pallas::Base>,
472    ) -> Result<(), plonk::Error> {
473        // ---------------------------------------------------------------
474        // Witness private inputs.
475        // ---------------------------------------------------------------
476
477        let vote_commitment = assign_free_advice(
478            layouter.namespace(|| "witness vote_commitment"),
479            config.advices[0],
480            self.vote_commitment,
481        )?;
482        // Clone for conditions 2 and 5 (Merkle path in condition 1 copies
483        // the cell, so the original reference remains valid).
484        let vote_commitment_cond2 = vote_commitment.clone();
485        let vote_commitment_cond5 = vote_commitment.clone();
486
487        let share_index = assign_free_advice(
488            layouter.namespace(|| "witness share_index"),
489            config.advices[0],
490            self.share_index,
491        )?;
492        let share_index_cond5 = share_index.clone();
493
494        let primary_blind = assign_free_advice(
495            layouter.namespace(|| "witness primary_blind"),
496            config.advices[0],
497            self.primary_blind,
498        )?;
499        let primary_blind_cond5 = primary_blind.clone();
500
501        // Copy proposal_id and vote_decision from instance into advice.
502        let proposal_id = layouter.assign_region(
503            || "copy proposal_id from instance",
504            |mut region| {
505                region.assign_advice_from_instance(
506                    || "proposal_id",
507                    config.primary,
508                    PROPOSAL_ID_PUBLIC_OFFSET,
509                    config.advices[0],
510                    0,
511                )
512            },
513        )?;
514
515        let vote_decision = layouter.assign_region(
516            || "copy vote_decision from instance",
517            |mut region| {
518                region.assign_advice_from_instance(
519                    || "vote_decision",
520                    config.primary,
521                    VOTE_DECISION_PUBLIC_OFFSET,
522                    config.advices[0],
523                    0,
524                )
525            },
526        )?;
527
528        // Copy voting_round_id from instance into advice.
529        // Used in condition 2 (vote commitment integrity).
530        let voting_round_id = layouter.assign_region(
531            || "copy voting_round_id from instance",
532            |mut region| {
533                region.assign_advice_from_instance(
534                    || "voting_round_id",
535                    config.primary,
536                    VOTING_ROUND_ID_PUBLIC_OFFSET,
537                    config.advices[0],
538                    0,
539                )
540            },
541        )?;
542        let voting_round_id_cond2 = voting_round_id;
543
544        // ---------------------------------------------------------------
545        // Witness 16 share_comms as private advice cells.
546        //
547        // Transitively bound to the public vote_comm_tree_root via:
548        //   share_comms → shares_hash → vote_commitment → Merkle root
549        // ---------------------------------------------------------------
550
551        let share_comms: [AssignedCell<pallas::Base, pallas::Base>; 16] = {
552            let mut cells = Vec::with_capacity(16);
553            for i in 0..16 {
554                cells.push(assign_free_advice(
555                    layouter.namespace(|| format!("witness share_comm[{i}]")),
556                    config.advices[0],
557                    self.share_comms[i],
558                )?);
559            }
560            cells.try_into().unwrap()
561        };
562
563        // Clone for condition 4 mux (condition 3's Poseidon consumes them).
564        let share_comms_cond4: [AssignedCell<pallas::Base, pallas::Base>; 16] =
565            core::array::from_fn(|i| share_comms[i].clone());
566
567        // ---------------------------------------------------------------
568        // Condition 3: Shares Hash Integrity.
569        //
570        // shares_hash = Poseidon(share_comm_0, ..., share_comm_15)
571        //
572        // The share_comms are private witnesses. Soundness comes from the
573        // transitive binding to the public tree root via condition 2 + 1.
574        // ---------------------------------------------------------------
575
576        let shares_hash = compute_shares_hash_from_comms_in_circuit(
577            config.poseidon_chip(),
578            layouter.namespace(|| "cond3: shares_hash from comms"),
579            share_comms,
580        )?;
581        let shares_hash_cond2 = shares_hash.clone();
582
583        // ---------------------------------------------------------------
584        // Condition 4: Primary Share Binding.
585        //
586        // The ciphertext coordinates are caller-supplied public inputs. ZKP #2
587        // publishes only the aggregate vote_commitment, not per-share
588        // ciphertext coordinates, so this is a transitive hash binding rather
589        // than a direct comparison against vote-proof public inputs.
590        //
591        // Proves that the ciphertext coordinates of the *revealed* share
592        // correspond to the share commitment at the declared
593        // `share_index`, by recomputing the commitment and matching it
594        // against the muxed-out `share_comms[share_index]`:
595        //   derived_comm = Poseidon(DOMAIN_SHARE_COMM, primary_blind,
596        //                           enc_c1_x, enc_c2_x, enc_c1_y, enc_c2_y)
597        //   share_comms[share_index] == derived_comm
598        //
599        // Defense-by-rejection: an adversary that has seen the on-chain
600        // ciphertexts but does not hold the blind cannot claim the wrong share
601        // is the revealed one. The recomputed commitment must match the muxed
602        // `share_comms[share_index]`; otherwise condition 4 rejects. The
603        // load-bearing assumption is Poseidon preimage resistance for the
604        // share-commitment hash shape owned by `crate::shares_hash`.
605        // ---------------------------------------------------------------
606
607        let enc_c1_x = layouter.assign_region(
608            || "copy enc_share_c1_x from instance",
609            |mut region| {
610                region.assign_advice_from_instance(
611                    || "enc_c1_x",
612                    config.primary,
613                    ENC_SHARE_C1_X_PUBLIC_OFFSET,
614                    config.advices[0],
615                    0,
616                )
617            },
618        )?;
619
620        let enc_c2_x = layouter.assign_region(
621            || "copy enc_share_c2_x from instance",
622            |mut region| {
623                region.assign_advice_from_instance(
624                    || "enc_c2_x",
625                    config.primary,
626                    ENC_SHARE_C2_X_PUBLIC_OFFSET,
627                    config.advices[0],
628                    0,
629                )
630            },
631        )?;
632
633        let enc_c1_y = layouter.assign_region(
634            || "copy enc_share_c1_y from instance",
635            |mut region| {
636                region.assign_advice_from_instance(
637                    || "enc_c1_y",
638                    config.primary,
639                    ENC_SHARE_C1_Y_PUBLIC_OFFSET,
640                    config.advices[0],
641                    0,
642                )
643            },
644        )?;
645
646        let enc_c2_y = layouter.assign_region(
647            || "copy enc_share_c2_y from instance",
648            |mut region| {
649                region.assign_advice_from_instance(
650                    || "enc_c2_y",
651                    config.primary,
652                    ENC_SHARE_C2_Y_PUBLIC_OFFSET,
653                    config.advices[0],
654                    0,
655                )
656            },
657        )?;
658
659        let domain_share_comm =
660            share_commitment::assign_domain_share_comm(&mut layouter, config.advices[0])?;
661        let derived_comm = hash_share_commitment_in_circuit(
662            config.poseidon_chip(),
663            layouter
664                .namespace(|| "cond4: Poseidon(DOMAIN_SHARE_COMM, blind, c1_x, c2_x, c1_y, c2_y)"),
665            domain_share_comm,
666            primary_blind,
667            enc_c1_x,
668            enc_c2_x,
669            enc_c1_y,
670            enc_c2_y,
671            0,
672        )?;
673
674        // Mux share_comms by share_index → selected_comm. The layout mirrors
675        // the gate definition in `configure`.
676        let selected_comm = layouter.assign_region(
677            || "cond4: share commitment mux",
678            |mut region| {
679                config.q_share_comm_mux.enable(&mut region, 0)?;
680
681                // Create a selector map
682                let sel_values: [Value<pallas::Base>; 16] = core::array::from_fn(|i| {
683                    self.share_index.map(|idx| {
684                        if idx == pallas::Base::from(i as u64) {
685                            pallas::Base::one()
686                        } else {
687                            pallas::Base::zero()
688                        }
689                    })
690                });
691
692                // Assign the one-hot selector bits into the region. We use assign_advice
693                // (fresh allocation) because sel_values are computed locally and have no
694                // prior cell to copy from. There are 16 bits spread across 9 advice
695                // columns, so they spill from row 0 into the first 7 columns of row 1.
696                // Layout table: (sel_start, count, advice_col_offset, row)
697                for (sel_start, count, col_off, row) in [(0, 9, 0, 0), (9, 7, 0, 1)] {
698                    for i in 0..count {
699                        region.assign_advice(
700                            || format!("sel_{}", sel_start + i),
701                            config.advices[col_off + i],
702                            row,
703                            || sel_values[sel_start + i],
704                        )?;
705                    }
706                }
707
708                // Copy the 16 share commitments into the region. We use copy_advice
709                // (equality-constrained copy) instead of assign_advice because these
710                // cells were allocated earlier in separate regions; copy_advice ties
711                // this cell to the original via the permutation argument, preventing
712                // the prover from substituting a different value. The 16 commitments
713                // also spill across multiple rows alongside the selector bits above.
714                // Layout table: (comm_start, count, advice_col_offset, row)
715                for (comm_start, count, col_off, row) in [(0, 2, 7, 1), (2, 9, 0, 2), (11, 5, 0, 3)]
716                {
717                    for i in 0..count {
718                        share_comms_cond4[comm_start + i].copy_advice(
719                            || format!("comm_{}", comm_start + i),
720                            &mut region,
721                            config.advices[col_off + i],
722                            row,
723                        )?;
724                    }
725                }
726
727                // Select the correct commitment via dot product selector.
728                // selected_comm_val = Σ sel[i] * comm[i]
729                let selected_comm_val =
730                    (0..16).fold(Value::known(pallas::Base::zero()), |acc, i| {
731                        acc.zip(sel_values[i])
732                            .zip(share_comms_cond4[i].value().copied())
733                            .map(|((a, s), c)| a + s * c)
734                    });
735                let selected_comm = region.assign_advice(
736                    || "selected_comm",
737                    config.advices[5],
738                    3,
739                    || selected_comm_val,
740                )?;
741
742                share_index.copy_advice(|| "share_index", &mut region, config.advices[6], 3)?;
743
744                Ok(selected_comm)
745            },
746        )?;
747
748        // Ensure that the derived commitment is equal to selected
749        layouter.assign_region(
750            || "cond4: derived_comm == selected_comm",
751            |mut region| region.constrain_equal(derived_comm.cell(), selected_comm.cell()),
752        )?;
753
754        // ---------------------------------------------------------------
755        // Condition 2: Vote Commitment Integrity.
756        //
757        // vote_commitment = Poseidon(DOMAIN_VC, voting_round_id,
758        //                            shares_hash, proposal_id, vote_decision)
759        //
760        // Same hash as the shared vote-commitment helper and the vote
761        // commitment tree.
762        // ---------------------------------------------------------------
763
764        // DOMAIN_VC constant (baked into the VK).
765        let domain_vc = config.assign_constant(
766            &mut layouter,
767            "cond2: DOMAIN_VC constant",
768            pallas::Base::from(vote_commitment::DOMAIN_VC),
769        )?;
770
771        let derived_vc = vote_commitment::vote_commitment_poseidon(
772            &config.poseidon_config,
773            &mut layouter,
774            "cond2",
775            domain_vc,
776            voting_round_id_cond2,
777            shares_hash_cond2,
778            proposal_id,
779            vote_decision,
780        )?;
781
782        // Constrain derived vote_commitment == witnessed vote_commitment.
783        layouter.assign_region(
784            || "cond2: vote_commitment equality",
785            |mut region| region.constrain_equal(derived_vc.cell(), vote_commitment_cond2.cell()),
786        )?;
787
788        // ---------------------------------------------------------------
789        // Condition 1: VC Membership.
790        //
791        // MerklePath(vote_commitment, position, path) = vote_comm_tree_root
792        //
793        // 24-level Poseidon Merkle path (LSB-first position bits).
794        // Uses the shared poseidon_merkle gadget.
795        // ---------------------------------------------------------------
796        {
797            let root = synthesize_poseidon_merkle_path::<VOTE_COMM_TREE_DEPTH>(
798                &config.merkle_swap,
799                &config.poseidon_config,
800                &mut layouter,
801                config.advices[0],
802                vote_commitment,
803                self.vote_comm_tree_position,
804                self.vote_comm_tree_path,
805                "cond1: merkle",
806            )?;
807
808            // Bind the computed Merkle root to the public input.
809            layouter.constrain_instance(
810                root.cell(),
811                config.primary,
812                VOTE_COMM_TREE_ROOT_PUBLIC_OFFSET,
813            )?;
814        }
815
816        // ---------------------------------------------------------------
817        // Condition 5: Share Nullifier Integrity.
818        //
819        // share_nullifier = Poseidon(domain_tag, vote_commitment, share_index,
820        //                            blind)
821        //
822        // Single ConstantLength<4> Poseidon hash (2 permutations at rate=2).
823        // blind is the share commitment blinding factor — the secret that
824        // makes the nullifier non-derivable from public on-chain data.
825        // Unlike ciphertext coordinates (c1_x, c2_x), the blind is never
826        // posted on-chain, so an observer cannot enumerate vote commitments
827        // to link nullifiers to their source.
828        // Round, proposal, decision, and shares_hash binding is transitive
829        // through vote_commitment; share_comms bind one hop earlier through
830        // shares_hash. A wrong public `vote_decision` or a wrong private
831        // share-commitment set changes the condition-2 commitment and is
832        // rejected by the Merkle path binding in condition 1.
833        // ---------------------------------------------------------------
834        {
835            // "share spend" domain tag — constant-constrained so the
836            // value is baked into the verification key.
837            let domain_tag = config.assign_constant(
838                &mut layouter,
839                "cond5: DOMAIN_SHARE_SPEND constant",
840                domain_tag_share_spend(),
841            )?;
842
843            let share_nullifier = PoseidonHash::<
844                pallas::Base,
845                _,
846                poseidon::P128Pow5T3,
847                ConstantLength<4>,
848                3,
849                2,
850            >::init(
851                config.poseidon_chip(),
852                layouter.namespace(|| "cond5: share nullifier Poseidon init"),
853            )?
854            .hash(
855                layouter.namespace(|| "cond5: Poseidon(tag, vc, idx, blind)"),
856                [
857                    domain_tag,
858                    vote_commitment_cond5,
859                    share_index_cond5,
860                    primary_blind_cond5,
861                ],
862            )?;
863
864            layouter.constrain_instance(
865                share_nullifier.cell(),
866                config.primary,
867                SHARE_NULLIFIER_PUBLIC_OFFSET,
868            )?;
869        }
870
871        Ok(())
872    }
873}
874
875// ================================================================
876// Instance (public inputs)
877// ================================================================
878
879/// Public inputs to the Share Reveal circuit (9 field elements).
880///
881/// The voting client (prover) chooses these values when assembling the
882/// proof; the verifier accepts them as the binding the proof must
883/// satisfy and checks the proof without seeing any private witnesses.
884/// The relationship is asymmetric: a malicious-custody client can
885/// choose any public-input vector it likes, so the verifier must source
886/// the *correct* values from authenticated chain state (see
887/// [`crate::share_reveal::prove::verify_share_reveal_proof`] for which
888/// fields require caller authentication versus which are proof-attested
889/// outputs).
890///
891/// The struct field order preserves the existing API layout and is not the
892/// Halo2 public input order. Use [`Self::to_halo2_instance`] and the
893/// `*_PUBLIC_OFFSET` constants as the canonical public-input mapping.
894#[derive(Clone, Debug)]
895pub struct Instance {
896    /// Poseidon nullifier for this share (prevents double-counting).
897    pub share_nullifier: pallas::Base,
898    /// Caller-supplied x-coordinate of the revealed share's El Gamal C1
899    /// component, bound through condition 4's selected share commitment.
900    pub enc_share_c1_x: pallas::Base,
901    /// Caller-supplied x-coordinate of the revealed share's El Gamal C2
902    /// component, bound through condition 4's selected share commitment.
903    pub enc_share_c2_x: pallas::Base,
904    /// Which proposal this vote is for.
905    pub proposal_id: pallas::Base,
906    /// The voter's choice.
907    pub vote_decision: pallas::Base,
908    /// Root of the vote commitment tree at anchor height.
909    pub vote_comm_tree_root: pallas::Base,
910    /// The voting round identifier.
911    pub voting_round_id: pallas::Base,
912    /// Caller-supplied y-coordinate of the revealed share's El Gamal C1
913    /// component.
914    ///
915    /// Binds the proof to the exact curve point, preventing sign-malleability.
916    /// This is transitively bound through the selected share commitment, not
917    /// directly recovered from vote-proof public inputs.
918    pub enc_share_c1_y: pallas::Base,
919    /// Caller-supplied y-coordinate of the revealed share's El Gamal C2
920    /// component, transitively bound through the selected share commitment.
921    pub enc_share_c2_y: pallas::Base,
922}
923
924impl Instance {
925    /// Number of public inputs serialized by [`Self::to_halo2_instance`].
926    pub const NUM_PUBLIC_INPUTS: usize = 9;
927
928    /// Constructs an [`Instance`] from its constituent parts.
929    ///
930    /// Callers should authenticate `proposal_id`, `vote_decision`,
931    /// `vote_comm_tree_root`, and `voting_round_id` out-of-band before
932    /// passing them here — see
933    /// [`crate::share_reveal::prove::verify_share_reveal_proof`] for the
934    /// trust contract. The ciphertext coordinate fields are caller-supplied
935    /// reveal data bound through
936    /// `Poseidon(blind, c1_x, c2_x, c1_y, c2_y) = share_comm[share_index]`
937    /// and the transitive `share_comm -> shares_hash -> vote_commitment`
938    /// chain; they are not direct public outputs of ZKP #2. The remaining
939    /// fields are proof-attested outputs derived outside the circuit but
940    /// constrained in-circuit against authenticated inputs and private
941    /// witnesses.
942    #[allow(clippy::too_many_arguments)]
943    pub fn from_parts(
944        share_nullifier: pallas::Base,
945        enc_share_c1_x: pallas::Base,
946        enc_share_c2_x: pallas::Base,
947        proposal_id: pallas::Base,
948        vote_decision: pallas::Base,
949        vote_comm_tree_root: pallas::Base,
950        voting_round_id: pallas::Base,
951        enc_share_c1_y: pallas::Base,
952        enc_share_c2_y: pallas::Base,
953    ) -> Self {
954        Instance {
955            share_nullifier,
956            enc_share_c1_x,
957            enc_share_c2_x,
958            proposal_id,
959            vote_decision,
960            vote_comm_tree_root,
961            voting_round_id,
962            enc_share_c1_y,
963            enc_share_c2_y,
964        }
965    }
966
967    /// Serializes public inputs for halo2 proof creation/verification.
968    ///
969    /// The order must match the instance column offsets defined at the
970    /// top of this file.
971    pub fn to_halo2_instance(&self) -> Vec<vesta::Scalar> {
972        vec![
973            self.share_nullifier,
974            self.enc_share_c1_x,
975            self.enc_share_c1_y,
976            self.enc_share_c2_x,
977            self.enc_share_c2_y,
978            self.proposal_id,
979            self.vote_decision,
980            self.vote_comm_tree_root,
981            self.voting_round_id,
982        ]
983    }
984}
985
986// ================================================================
987// Tests
988// ================================================================
989
990#[cfg(test)]
991mod tests {
992    use super::*;
993    use ff::PrimeField;
994    use group::Curve;
995    use halo2_proofs::dev::MockProver;
996    use pasta_curves::pallas;
997
998    use crate::circuit::elgamal::{elgamal_encrypt, spend_auth_g_affine};
999    use crate::circuit::vote_commitment::vote_commitment_hash as compute_vote_commitment_hash;
1000    use crate::shares_hash::{share_commitment, shares_hash as compute_shares_hash};
1001    use crate::vote_proof::poseidon_hash_2;
1002
1003    #[test]
1004    fn instance_to_halo2_instance_uses_public_input_offsets() {
1005        let share_nullifier = pallas::Base::from(10u64);
1006        let enc_share_c1_x = pallas::Base::from(11u64);
1007        let enc_share_c1_y = pallas::Base::from(12u64);
1008        let enc_share_c2_x = pallas::Base::from(13u64);
1009        let enc_share_c2_y = pallas::Base::from(14u64);
1010        let proposal_id = pallas::Base::from(15u64);
1011        let vote_decision = pallas::Base::from(16u64);
1012        let vote_comm_tree_root = pallas::Base::from(17u64);
1013        let voting_round_id = pallas::Base::from(18u64);
1014
1015        let instance = Instance {
1016            share_nullifier,
1017            enc_share_c1_x,
1018            enc_share_c2_x,
1019            proposal_id,
1020            vote_decision,
1021            vote_comm_tree_root,
1022            voting_round_id,
1023            enc_share_c1_y,
1024            enc_share_c2_y,
1025        };
1026
1027        let public_inputs = instance.to_halo2_instance();
1028
1029        assert_eq!(public_inputs.len(), Instance::NUM_PUBLIC_INPUTS);
1030        assert_eq!(
1031            public_inputs[SHARE_NULLIFIER_PUBLIC_OFFSET],
1032            share_nullifier
1033        );
1034        assert_eq!(public_inputs[ENC_SHARE_C1_X_PUBLIC_OFFSET], enc_share_c1_x);
1035        assert_eq!(public_inputs[ENC_SHARE_C1_Y_PUBLIC_OFFSET], enc_share_c1_y);
1036        assert_eq!(public_inputs[ENC_SHARE_C2_X_PUBLIC_OFFSET], enc_share_c2_x);
1037        assert_eq!(public_inputs[ENC_SHARE_C2_Y_PUBLIC_OFFSET], enc_share_c2_y);
1038        assert_eq!(public_inputs[PROPOSAL_ID_PUBLIC_OFFSET], proposal_id);
1039        assert_eq!(public_inputs[VOTE_DECISION_PUBLIC_OFFSET], vote_decision);
1040        assert_eq!(
1041            public_inputs[VOTE_COMM_TREE_ROOT_PUBLIC_OFFSET],
1042            vote_comm_tree_root
1043        );
1044        assert_eq!(
1045            public_inputs[VOTING_ROUND_ID_PUBLIC_OFFSET],
1046            voting_round_id
1047        );
1048    }
1049
1050    fn generate_ea_keypair() -> (pallas::Scalar, pallas::Point, pallas::Affine) {
1051        let ea_sk = pallas::Scalar::from(42u64);
1052        let g = pallas::Point::from(spend_auth_g_affine());
1053        let ea_pk = g * ea_sk;
1054        let ea_pk_affine = ea_pk.to_affine();
1055        (ea_sk, ea_pk, ea_pk_affine)
1056    }
1057
1058    /// Returns `(c1_x, c2_x, c1_y, c2_y, share_blinds, share_comms, shares_hash_value)`.
1059    fn encrypt_shares(
1060        shares: [u64; 16],
1061        ea_pk: pallas::Point,
1062    ) -> (
1063        [pallas::Base; 16],
1064        [pallas::Base; 16],
1065        [pallas::Base; 16],
1066        [pallas::Base; 16],
1067        [pallas::Base; 16],
1068        [pallas::Base; 16],
1069        pallas::Base,
1070    ) {
1071        let mut c1_x = [pallas::Base::zero(); 16];
1072        let mut c2_x = [pallas::Base::zero(); 16];
1073        let mut c1_y = [pallas::Base::zero(); 16];
1074        let mut c2_y = [pallas::Base::zero(); 16];
1075        let randomness: [pallas::Base; 16] =
1076            core::array::from_fn(|i| pallas::Base::from((i as u64 + 1) * 101));
1077        let share_blinds: [pallas::Base; 16] =
1078            core::array::from_fn(|i| pallas::Base::from(1001u64 + i as u64));
1079        for i in 0..16 {
1080            let (cx1, cx2, cy1, cy2) =
1081                elgamal_encrypt(pallas::Base::from(shares[i]), randomness[i], ea_pk)
1082                    .expect("test encryption inputs should be valid");
1083            c1_x[i] = cx1;
1084            c2_x[i] = cx2;
1085            c1_y[i] = cy1;
1086            c2_y[i] = cy2;
1087        }
1088        let comms: [pallas::Base; 16] = core::array::from_fn(|i| {
1089            share_commitment(share_blinds[i], c1_x[i], c2_x[i], c1_y[i], c2_y[i])
1090        });
1091        let hash = compute_shares_hash(share_blinds, c1_x, c2_x, c1_y, c2_y);
1092        (c1_x, c2_x, c1_y, c2_y, share_blinds, comms, hash)
1093    }
1094
1095    fn make_test_data(share_idx: u32) -> (Circuit, Instance) {
1096        let (circuit, instance, _) = make_test_ballot(share_idx, [625; 16]);
1097        (circuit, instance)
1098    }
1099
1100    fn make_test_ballot(
1101        share_idx: u32,
1102        shares_u64: [u64; 16],
1103    ) -> (Circuit, Instance, pallas::Base) {
1104        let proposal_id = pallas::Base::from(3u64);
1105        let vote_decision = pallas::Base::from(1u64);
1106        let voting_round_id = pallas::Base::from(999u64);
1107
1108        let (_ea_sk, ea_pk_point, _ea_pk_affine) = generate_ea_keypair();
1109        let (enc_c1_x, enc_c2_x, enc_c1_y, enc_c2_y, share_blinds, share_comms, shares_hash_val) =
1110            encrypt_shares(shares_u64, ea_pk_point);
1111
1112        let vote_commitment = compute_vote_commitment_hash(
1113            voting_round_id,
1114            shares_hash_val,
1115            proposal_id,
1116            vote_decision,
1117        );
1118
1119        let (auth_path, position, vote_comm_tree_root) =
1120            build_single_leaf_merkle_path(vote_commitment);
1121
1122        let share_index_fp = pallas::Base::from(share_idx as u64);
1123        let share_nullifier = share_nullifier_hash(
1124            vote_commitment,
1125            share_index_fp,
1126            share_blinds[share_idx as usize],
1127        );
1128
1129        let circuit = Circuit {
1130            vote_comm_tree_path: Value::known(auth_path),
1131            vote_comm_tree_position: Value::known(position),
1132            share_comms: share_comms.map(Value::known),
1133            primary_blind: Value::known(share_blinds[share_idx as usize]),
1134            share_index: Value::known(share_index_fp),
1135            vote_commitment: Value::known(vote_commitment),
1136        };
1137
1138        let instance = Instance::from_parts(
1139            share_nullifier,
1140            enc_c1_x[share_idx as usize],
1141            enc_c2_x[share_idx as usize],
1142            proposal_id,
1143            vote_decision,
1144            vote_comm_tree_root,
1145            voting_round_id,
1146            enc_c1_y[share_idx as usize],
1147            enc_c2_y[share_idx as usize],
1148        );
1149
1150        (circuit, instance, vote_commitment)
1151    }
1152
1153    fn build_single_leaf_merkle_path(
1154        leaf: pallas::Base,
1155    ) -> ([pallas::Base; VOTE_COMM_TREE_DEPTH], u32, pallas::Base) {
1156        let mut empty_roots = [pallas::Base::zero(); VOTE_COMM_TREE_DEPTH];
1157        empty_roots[0] = poseidon_hash_2(pallas::Base::zero(), pallas::Base::zero());
1158        for i in 1..VOTE_COMM_TREE_DEPTH {
1159            empty_roots[i] = poseidon_hash_2(empty_roots[i - 1], empty_roots[i - 1]);
1160        }
1161
1162        let auth_path = empty_roots;
1163        let mut current = leaf;
1164        for i in 0..VOTE_COMM_TREE_DEPTH {
1165            current = poseidon_hash_2(current, auth_path[i]);
1166        }
1167        (auth_path, 0, current)
1168    }
1169
1170    #[test]
1171    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1172    fn test_share_reveal_valid() {
1173        let (circuit, instance) = make_test_data(0);
1174        let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1175        assert_eq!(prover.verify(), Ok(()));
1176    }
1177
1178    #[test]
1179    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1180    fn test_share_reveal_valid_index_1() {
1181        let (circuit, instance) = make_test_data(1);
1182        let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1183        assert_eq!(prover.verify(), Ok(()));
1184    }
1185
1186    #[test]
1187    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1188    fn test_share_reveal_valid_index_2() {
1189        let (circuit, instance) = make_test_data(2);
1190        let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1191        assert_eq!(prover.verify(), Ok(()));
1192    }
1193
1194    #[test]
1195    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1196    fn test_share_reveal_valid_index_3() {
1197        let (circuit, instance) = make_test_data(3);
1198        let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1199        assert_eq!(prover.verify(), Ok(()));
1200    }
1201
1202    #[test]
1203    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1204    fn test_share_reveal_valid_index_15() {
1205        let (circuit, instance) = make_test_data(15);
1206        let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1207        assert_eq!(prover.verify(), Ok(()));
1208    }
1209
1210    #[test]
1211    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1212    fn test_share_reveal_wrong_merkle_root() {
1213        let (circuit, mut instance) = make_test_data(0);
1214        instance.vote_comm_tree_root = pallas::Base::from(12345u64);
1215        let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1216        assert!(prover.verify().is_err());
1217    }
1218
1219    #[test]
1220    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1221    fn test_share_reveal_wrong_nullifier() {
1222        let (circuit, mut instance) = make_test_data(0);
1223        instance.share_nullifier = pallas::Base::from(99999u64);
1224        let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1225        assert!(prover.verify().is_err());
1226    }
1227
1228    #[test]
1229    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1230    fn test_share_reveal_wrong_share_index() {
1231        let (circuit, instance) = make_test_data(0);
1232        let bad_instance = Instance::from_parts(
1233            instance.share_nullifier,
1234            pallas::Base::from(999u64),
1235            pallas::Base::from(888u64),
1236            instance.proposal_id,
1237            instance.vote_decision,
1238            instance.vote_comm_tree_root,
1239            instance.voting_round_id,
1240            instance.enc_share_c1_y,
1241            instance.enc_share_c2_y,
1242        );
1243        let prover = MockProver::run(K, &circuit, vec![bad_instance.to_halo2_instance()]).unwrap();
1244        assert!(prover.verify().is_err());
1245    }
1246
1247    #[test]
1248    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1249    fn test_share_reveal_wrong_vote_decision() {
1250        let (circuit, mut instance) = make_test_data(0);
1251        instance.vote_decision = pallas::Base::from(42u64);
1252        let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1253        assert!(prover.verify().is_err());
1254    }
1255
1256    #[test]
1257    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1258    fn test_share_reveal_wrong_voting_round_id() {
1259        let (circuit, mut instance) = make_test_data(0);
1260        instance.voting_round_id = pallas::Base::from(12345u64);
1261        let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1262        assert!(prover.verify().is_err());
1263    }
1264
1265    #[test]
1266    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1267    fn test_share_reveal_cannot_replay_across_vote_commitments() {
1268        let share_idx = 0;
1269        let (circuit_a, instance_a, vote_commitment_a) = make_test_ballot(share_idx, [625; 16]);
1270        let (_circuit_b, instance_b, vote_commitment_b) = make_test_ballot(share_idx, [626; 16]);
1271
1272        assert_eq!(instance_a.voting_round_id, instance_b.voting_round_id);
1273        assert_eq!(instance_a.proposal_id, instance_b.proposal_id);
1274        assert_eq!(instance_a.vote_decision, instance_b.vote_decision);
1275        assert_ne!(vote_commitment_a, vote_commitment_b);
1276        assert_ne!(
1277            instance_a.vote_comm_tree_root,
1278            instance_b.vote_comm_tree_root
1279        );
1280
1281        let prover_a =
1282            MockProver::run(K, &circuit_a, vec![instance_a.to_halo2_instance()]).unwrap();
1283        assert_eq!(prover_a.verify(), Ok(()));
1284
1285        // Reuse ballot A's reveal witnesses, but authenticate them against
1286        // ballot B's distinct vote commitment tree root.
1287        let mut replay_instance = instance_a.clone();
1288        replay_instance.vote_comm_tree_root = instance_b.vote_comm_tree_root;
1289        let replay_prover =
1290            MockProver::run(K, &circuit_a, vec![replay_instance.to_halo2_instance()]).unwrap();
1291        assert!(replay_prover.verify().is_err());
1292    }
1293
1294    /// Proves that flipping c1_y to -c1_y (sign malleability) is detected.
1295    /// The share reveal circuit binds to the full curve point via
1296    /// Poseidon(DOMAIN_SHARE_COMM, blind, c1_x, c2_x, c1_y, c2_y).
1297    /// Negating c1_y changes the commitment, so the proof must fail.
1298    #[test]
1299    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1300    fn test_share_reveal_sign_flip_detected() {
1301        let (circuit, mut instance) = make_test_data(0);
1302        instance.enc_share_c1_y = -instance.enc_share_c1_y;
1303        let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1304        assert!(prover.verify().is_err());
1305    }
1306
1307    /// Tampers with share_comms[5] (a share other than the primary share at index 0).
1308    /// The share_comms are private witnesses but transitively bound to the public
1309    /// vote_comm_tree_root via:
1310    ///   share_comms → shares_hash (condition 3)
1311    ///   shares_hash → vote_commitment (condition 2)
1312    ///   vote_commitment → Merkle root (condition 1)
1313    /// Changing any share_comm alters shares_hash → vote_commitment, so the Merkle
1314    /// root computed in-circuit no longer matches the public instance root.
1315    #[test]
1316    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1317    fn test_share_reveal_tampered_share_comms_fails() {
1318        let (mut circuit, instance) = make_test_data(0);
1319
1320        // Replace share_comms[5] (index ≠ primary share index 0) with a wrong value.
1321        // Any single-field substitution propagates through shares_hash → vote_commitment
1322        // → Merkle root, invalidating condition 1.
1323        circuit.share_comms[5] = Value::known(pallas::Base::from(99999u64));
1324
1325        let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1326        // Must fail: tampered share_comm → wrong shares_hash → wrong vote_commitment
1327        // → Merkle root computed in-circuit ≠ instance.vote_comm_tree_root.
1328        assert!(prover.verify().is_err());
1329    }
1330
1331    #[test]
1332    fn share_nullifier_tracks_shares_hash_through_vote_commitment() {
1333        let voting_round_id = pallas::Base::from(42u64);
1334        let proposal_id = pallas::Base::from(7u64);
1335        let vote_decision = pallas::Base::from(1u64);
1336        let shares_hash_a = pallas::Base::from(100u64);
1337        let shares_hash_b = pallas::Base::from(101u64);
1338        let share_index = pallas::Base::from(3u64);
1339        let blind = pallas::Base::from(200u64);
1340
1341        let vote_commitment_a = compute_vote_commitment_hash(
1342            voting_round_id,
1343            shares_hash_a,
1344            proposal_id,
1345            vote_decision,
1346        );
1347        let vote_commitment_b = compute_vote_commitment_hash(
1348            voting_round_id,
1349            shares_hash_b,
1350            proposal_id,
1351            vote_decision,
1352        );
1353        assert_ne!(vote_commitment_a, vote_commitment_b);
1354
1355        let share_nullifier_a = share_nullifier_hash(vote_commitment_a, share_index, blind);
1356        let share_nullifier_b = share_nullifier_hash(vote_commitment_b, share_index, blind);
1357        assert_ne!(share_nullifier_a, share_nullifier_b);
1358
1359        assert_eq!(
1360            vote_commitment_a.to_repr(),
1361            [
1362                246, 84, 48, 178, 227, 178, 234, 71, 2, 178, 177, 211, 238, 120, 238, 157, 174, 5,
1363                29, 244, 76, 128, 250, 245, 139, 137, 84, 246, 108, 197, 47, 31,
1364            ]
1365        );
1366        assert_eq!(
1367            vote_commitment_b.to_repr(),
1368            [
1369                153, 178, 215, 171, 108, 162, 193, 164, 62, 112, 205, 83, 186, 133, 99, 176, 44,
1370                202, 218, 73, 114, 189, 204, 58, 82, 13, 52, 188, 69, 70, 131, 3,
1371            ]
1372        );
1373        assert_eq!(
1374            share_nullifier_a.to_repr(),
1375            [
1376                119, 176, 211, 29, 114, 129, 188, 150, 122, 163, 222, 136, 21, 250, 159, 126, 139,
1377                224, 205, 109, 60, 84, 112, 66, 101, 139, 161, 62, 127, 17, 37, 22,
1378            ]
1379        );
1380        assert_eq!(
1381            share_nullifier_b.to_repr(),
1382            [
1383                244, 6, 225, 7, 34, 104, 123, 192, 48, 94, 4, 222, 156, 224, 137, 204, 121, 90, 18,
1384                186, 234, 235, 223, 30, 101, 75, 79, 249, 44, 11, 24, 59,
1385            ]
1386        );
1387    }
1388
1389    #[test]
1390    fn share_nullifier_hash_frozen_vector() {
1391        assert_eq!(
1392            share_nullifier_hash(
1393                pallas::Base::from(42u64),
1394                pallas::Base::from(3u64),
1395                pallas::Base::from(200u64),
1396            ),
1397            pallas::Base::from_repr([
1398                103, 140, 231, 81, 182, 191, 8, 141, 126, 173, 35, 129, 94, 244, 230, 146, 27, 161,
1399                255, 223, 211, 230, 26, 212, 86, 62, 15, 167, 99, 237, 233, 63,
1400            ])
1401            .expect("frozen vector must be canonical")
1402        );
1403    }
1404
1405    #[test]
1406    fn test_share_reveal_domain_tag_matches_server() {
1407        assert_eq!(domain_tag_share_spend(), crate::domain_tags::share_spend());
1408    }
1409
1410    /// Measures actual rows used by the share-reveal circuit via `CircuitCost::measure`.
1411    ///
1412    /// `CircuitCost` runs the floor planner against the circuit and tracks the
1413    /// highest row offset assigned in any column, giving the real "rows consumed"
1414    /// number rather than the theoretical 2^K capacity.
1415    ///
1416    /// Run with:
1417    ///   cargo test row_budget -- --nocapture --ignored
1418    #[test]
1419    #[ignore = "long-running row-budget diagnostic; run with `cargo test row_budget -- --ignored --nocapture`"]
1420    fn row_budget() {
1421        use halo2_proofs::dev::CircuitCost;
1422        use pasta_curves::vesta;
1423        use std::println;
1424
1425        let (circuit, _) = make_test_data(0);
1426
1427        let cost = CircuitCost::<vesta::Point, _>::measure(K, &circuit);
1428        let debug = format!("{cost:?}");
1429
1430        let extract = |field: &str| -> usize {
1431            let prefix = format!("{field}: ");
1432            debug
1433                .split(&prefix)
1434                .nth(1)
1435                .and_then(|s| s.split([',', ' ', '}']).next())
1436                .and_then(|n| n.parse().ok())
1437                .unwrap_or(0)
1438        };
1439
1440        let max_rows = extract("max_rows");
1441        let max_advice_rows = extract("max_advice_rows");
1442        let max_fixed_rows = extract("max_fixed_rows");
1443        let total_available = 1usize << K;
1444
1445        println!("=== share-reveal circuit row budget (K={K}) ===");
1446        println!("  max_rows (floor-planner high-water mark): {max_rows}");
1447        println!("  max_advice_rows:                          {max_advice_rows}");
1448        println!("  max_fixed_rows:                           {max_fixed_rows}");
1449        println!("  2^K  (total available rows):              {total_available}");
1450        println!(
1451            "  headroom:                                 {}",
1452            total_available.saturating_sub(max_rows)
1453        );
1454        println!(
1455            "  utilisation:                              {:.1}%",
1456            100.0 * max_rows as f64 / total_available as f64
1457        );
1458        println!();
1459        println!("  Full debug: {debug}");
1460
1461        // Witness-independence check: Circuit::default() (all unknowns)
1462        // must produce exactly the same layout as the filled circuit.
1463        let cost_default = CircuitCost::<vesta::Point, _>::measure(K, &Circuit::default());
1464        let debug_default = format!("{cost_default:?}");
1465        let max_rows_default = debug_default
1466            .split("max_rows: ")
1467            .nth(1)
1468            .and_then(|s| s.split([',', ' ', '}']).next())
1469            .and_then(|n| n.parse::<usize>().ok())
1470            .unwrap_or(0);
1471        if max_rows_default == max_rows {
1472            println!(
1473                "  Witness-independence: PASS \
1474                (Circuit::default() max_rows={max_rows_default} == filled max_rows={max_rows})"
1475            );
1476        } else {
1477            println!(
1478                "  Witness-independence: FAIL \
1479                (Circuit::default() max_rows={max_rows_default} != filled max_rows={max_rows}) \
1480                — row count depends on witness values!"
1481            );
1482        }
1483
1484        println!("  VOTE_COMM_TREE_DEPTH (circuit constant): {VOTE_COMM_TREE_DEPTH}");
1485
1486        // Minimum-K probe: find the smallest K at which MockProver passes.
1487        for probe_k in 11u32..=K {
1488            let (c, inst) = make_test_data(0);
1489            match MockProver::run(probe_k, &c, vec![inst.to_halo2_instance()]) {
1490                Err(_) => {
1491                    println!("  K={probe_k}: not enough rows (synthesizer rejected)");
1492                    continue;
1493                }
1494                Ok(p) => match p.verify() {
1495                    Ok(()) => {
1496                        println!("  Minimum viable K: {probe_k} (2^{probe_k} = {} rows, {:.1}% headroom)",
1497                            1usize << probe_k,
1498                            100.0 * (1.0 - max_rows as f64 / (1usize << probe_k) as f64));
1499                        break;
1500                    }
1501                    Err(_) => println!("  K={probe_k}: too small"),
1502                },
1503            }
1504        }
1505    }
1506}