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