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::gadgets::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_gadgets::{
62    poseidon::{
63        primitives::{self as poseidon, ConstantLength},
64        Hash as PoseidonHash, Pow5Chip as PoseidonChip, Pow5Config as PoseidonConfig,
65    },
66    utilities::bool_check,
67};
68use halo2_proofs::{
69    circuit::{floor_planner, AssignedCell, Layouter, Value},
70    plonk::{
71        self, Advice, Column, ConstraintSystem, Constraints, Expression, Fixed,
72        Instance as InstanceColumn, Selector,
73    },
74    poly::Rotation,
75};
76use itertools::Itertools;
77use orchard::circuit::gadget::assign_free_advice;
78use pasta_curves::{pallas, vesta};
79
80use crate::{
81    gadgets::{
82        poseidon_merkle::{synthesize_poseidon_merkle_path, MerkleSwapGate},
83        vote_commitment,
84    },
85    params::VOTE_COMM_TREE_DEPTH,
86    shares_hash::{compute_shares_hash_from_comms_in_circuit, hash_share_commitment_in_circuit},
87};
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).
109const 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.
116const 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.
124const 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.
129const 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`.
134const ENC_SHARE_C2_Y_PUBLIC_OFFSET: usize = 4;
135/// Public input offset for the proposal identifier.
136const PROPOSAL_ID_PUBLIC_OFFSET: usize = 5;
137/// Public input offset for the vote decision.
138const VOTE_DECISION_PUBLIC_OFFSET: usize = 6;
139/// Public input offset for the vote commitment tree root.
140const 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").
150const 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    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    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(super) vote_comm_tree_path: Value<[pallas::Base; VOTE_COMM_TREE_DEPTH]>,
258    /// Leaf position in the vote commitment tree.
259    pub(super) 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(super) 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(super) primary_blind: Value<pallas::Base>,
280
281    // === Share selection ===
282    /// Which of the 16 shares is being revealed (0..15).
283    pub(super) 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(super) 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    pub fn from_parts(
942        share_nullifier: pallas::Base,
943        enc_share_c1_x: pallas::Base,
944        enc_share_c2_x: pallas::Base,
945        proposal_id: pallas::Base,
946        vote_decision: pallas::Base,
947        vote_comm_tree_root: pallas::Base,
948        voting_round_id: pallas::Base,
949        enc_share_c1_y: pallas::Base,
950        enc_share_c2_y: pallas::Base,
951    ) -> Self {
952        Instance {
953            share_nullifier,
954            enc_share_c1_x,
955            enc_share_c2_x,
956            proposal_id,
957            vote_decision,
958            vote_comm_tree_root,
959            voting_round_id,
960            enc_share_c1_y,
961            enc_share_c2_y,
962        }
963    }
964
965    /// Serializes public inputs for halo2 proof creation/verification.
966    ///
967    /// The order must match the instance column offsets defined at the
968    /// top of this file.
969    pub fn to_halo2_instance(&self) -> Vec<vesta::Scalar> {
970        vec![
971            self.share_nullifier,
972            self.enc_share_c1_x,
973            self.enc_share_c1_y,
974            self.enc_share_c2_x,
975            self.enc_share_c2_y,
976            self.proposal_id,
977            self.vote_decision,
978            self.vote_comm_tree_root,
979            self.voting_round_id,
980        ]
981    }
982}
983
984// ================================================================
985// Tests
986// ================================================================
987
988#[cfg(test)]
989mod tests {
990    use super::*;
991    use ff::PrimeField;
992    use group::Curve;
993    use halo2_proofs::dev::MockProver;
994    use pasta_curves::pallas;
995
996    use crate::gadgets::elgamal::{elgamal_encrypt, spend_auth_g_affine};
997    use crate::gadgets::vote_commitment::vote_commitment_hash as compute_vote_commitment_hash;
998    use crate::protocol_hash::poseidon_hash_2;
999    use crate::shares_hash::{share_commitment, shares_hash as compute_shares_hash};
1000
1001    #[test]
1002    fn instance_to_halo2_instance_uses_public_input_offsets() {
1003        let share_nullifier = pallas::Base::from(10u64);
1004        let enc_share_c1_x = pallas::Base::from(11u64);
1005        let enc_share_c1_y = pallas::Base::from(12u64);
1006        let enc_share_c2_x = pallas::Base::from(13u64);
1007        let enc_share_c2_y = pallas::Base::from(14u64);
1008        let proposal_id = pallas::Base::from(15u64);
1009        let vote_decision = pallas::Base::from(16u64);
1010        let vote_comm_tree_root = pallas::Base::from(17u64);
1011        let voting_round_id = pallas::Base::from(18u64);
1012
1013        let instance = Instance {
1014            share_nullifier,
1015            enc_share_c1_x,
1016            enc_share_c2_x,
1017            proposal_id,
1018            vote_decision,
1019            vote_comm_tree_root,
1020            voting_round_id,
1021            enc_share_c1_y,
1022            enc_share_c2_y,
1023        };
1024
1025        let public_inputs = instance.to_halo2_instance();
1026
1027        assert_eq!(public_inputs.len(), Instance::NUM_PUBLIC_INPUTS);
1028        assert_eq!(
1029            public_inputs[SHARE_NULLIFIER_PUBLIC_OFFSET],
1030            share_nullifier
1031        );
1032        assert_eq!(public_inputs[ENC_SHARE_C1_X_PUBLIC_OFFSET], enc_share_c1_x);
1033        assert_eq!(public_inputs[ENC_SHARE_C1_Y_PUBLIC_OFFSET], enc_share_c1_y);
1034        assert_eq!(public_inputs[ENC_SHARE_C2_X_PUBLIC_OFFSET], enc_share_c2_x);
1035        assert_eq!(public_inputs[ENC_SHARE_C2_Y_PUBLIC_OFFSET], enc_share_c2_y);
1036        assert_eq!(public_inputs[PROPOSAL_ID_PUBLIC_OFFSET], proposal_id);
1037        assert_eq!(public_inputs[VOTE_DECISION_PUBLIC_OFFSET], vote_decision);
1038        assert_eq!(
1039            public_inputs[VOTE_COMM_TREE_ROOT_PUBLIC_OFFSET],
1040            vote_comm_tree_root
1041        );
1042        assert_eq!(
1043            public_inputs[VOTING_ROUND_ID_PUBLIC_OFFSET],
1044            voting_round_id
1045        );
1046    }
1047
1048    fn generate_ea_keypair() -> (pallas::Scalar, pallas::Affine) {
1049        let ea_sk = pallas::Scalar::from(42u64);
1050        let ea_pk = (spend_auth_g_affine() * ea_sk).to_affine();
1051        (ea_sk, ea_pk)
1052    }
1053
1054    /// Returns `(c1_x, c2_x, c1_y, c2_y, share_blinds, share_comms, shares_hash_value)`.
1055    fn encrypt_shares(
1056        shares: [u64; 16],
1057        ea_pk: pallas::Affine,
1058    ) -> (
1059        [pallas::Base; 16],
1060        [pallas::Base; 16],
1061        [pallas::Base; 16],
1062        [pallas::Base; 16],
1063        [pallas::Base; 16],
1064        [pallas::Base; 16],
1065        pallas::Base,
1066    ) {
1067        let mut c1_x = [pallas::Base::zero(); 16];
1068        let mut c2_x = [pallas::Base::zero(); 16];
1069        let mut c1_y = [pallas::Base::zero(); 16];
1070        let mut c2_y = [pallas::Base::zero(); 16];
1071        let randomness: [pallas::Base; 16] =
1072            core::array::from_fn(|i| pallas::Base::from((i as u64 + 1) * 101));
1073        let share_blinds: [pallas::Base; 16] =
1074            core::array::from_fn(|i| pallas::Base::from(1001u64 + i as u64));
1075        for i in 0..16 {
1076            let (cx1, cx2, cy1, cy2) =
1077                elgamal_encrypt(pallas::Base::from(shares[i]), randomness[i], ea_pk)
1078                    .expect("test encryption inputs should be valid");
1079            c1_x[i] = cx1;
1080            c2_x[i] = cx2;
1081            c1_y[i] = cy1;
1082            c2_y[i] = cy2;
1083        }
1084        let comms: [pallas::Base; 16] = core::array::from_fn(|i| {
1085            share_commitment(share_blinds[i], c1_x[i], c2_x[i], c1_y[i], c2_y[i])
1086        });
1087        let hash = compute_shares_hash(share_blinds, c1_x, c2_x, c1_y, c2_y);
1088        (c1_x, c2_x, c1_y, c2_y, share_blinds, comms, hash)
1089    }
1090
1091    fn make_test_data(share_idx: u32) -> (Circuit, Instance) {
1092        let (circuit, instance, _) = make_test_ballot(share_idx, [625; 16]);
1093        (circuit, instance)
1094    }
1095
1096    fn make_test_ballot(
1097        share_idx: u32,
1098        shares_u64: [u64; 16],
1099    ) -> (Circuit, Instance, pallas::Base) {
1100        let proposal_id = pallas::Base::from(3u64);
1101        let vote_decision = pallas::Base::from(1u64);
1102        let voting_round_id = pallas::Base::from(999u64);
1103
1104        let (_ea_sk, ea_pk) = generate_ea_keypair();
1105        let (enc_c1_x, enc_c2_x, enc_c1_y, enc_c2_y, share_blinds, share_comms, shares_hash_val) =
1106            encrypt_shares(shares_u64, ea_pk);
1107
1108        let vote_commitment = compute_vote_commitment_hash(
1109            voting_round_id,
1110            shares_hash_val,
1111            proposal_id,
1112            vote_decision,
1113        );
1114
1115        let (auth_path, position, vote_comm_tree_root) =
1116            build_single_leaf_merkle_path(vote_commitment);
1117
1118        let share_index_fp = pallas::Base::from(share_idx as u64);
1119        let share_nullifier = share_nullifier_hash(
1120            vote_commitment,
1121            share_index_fp,
1122            share_blinds[share_idx as usize],
1123        );
1124
1125        let circuit = Circuit {
1126            vote_comm_tree_path: Value::known(auth_path),
1127            vote_comm_tree_position: Value::known(position),
1128            share_comms: share_comms.map(Value::known),
1129            primary_blind: Value::known(share_blinds[share_idx as usize]),
1130            share_index: Value::known(share_index_fp),
1131            vote_commitment: Value::known(vote_commitment),
1132        };
1133
1134        let instance = Instance::from_parts(
1135            share_nullifier,
1136            enc_c1_x[share_idx as usize],
1137            enc_c2_x[share_idx as usize],
1138            proposal_id,
1139            vote_decision,
1140            vote_comm_tree_root,
1141            voting_round_id,
1142            enc_c1_y[share_idx as usize],
1143            enc_c2_y[share_idx as usize],
1144        );
1145
1146        (circuit, instance, vote_commitment)
1147    }
1148
1149    fn build_single_leaf_merkle_path(
1150        leaf: pallas::Base,
1151    ) -> ([pallas::Base; VOTE_COMM_TREE_DEPTH], u32, pallas::Base) {
1152        let mut empty_roots = [pallas::Base::zero(); VOTE_COMM_TREE_DEPTH];
1153        empty_roots[0] = poseidon_hash_2(pallas::Base::zero(), pallas::Base::zero());
1154        for i in 1..VOTE_COMM_TREE_DEPTH {
1155            empty_roots[i] = poseidon_hash_2(empty_roots[i - 1], empty_roots[i - 1]);
1156        }
1157
1158        let auth_path = empty_roots;
1159        let mut current = leaf;
1160        for i in 0..VOTE_COMM_TREE_DEPTH {
1161            current = poseidon_hash_2(current, auth_path[i]);
1162        }
1163        (auth_path, 0, current)
1164    }
1165
1166    #[test]
1167    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1168    fn test_share_reveal_valid() {
1169        let (circuit, instance) = make_test_data(0);
1170        let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1171        assert_eq!(prover.verify(), Ok(()));
1172    }
1173
1174    #[test]
1175    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1176    fn test_share_reveal_valid_index_1() {
1177        let (circuit, instance) = make_test_data(1);
1178        let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1179        assert_eq!(prover.verify(), Ok(()));
1180    }
1181
1182    #[test]
1183    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1184    fn test_share_reveal_valid_index_2() {
1185        let (circuit, instance) = make_test_data(2);
1186        let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1187        assert_eq!(prover.verify(), Ok(()));
1188    }
1189
1190    #[test]
1191    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1192    fn test_share_reveal_valid_index_3() {
1193        let (circuit, instance) = make_test_data(3);
1194        let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1195        assert_eq!(prover.verify(), Ok(()));
1196    }
1197
1198    #[test]
1199    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1200    fn test_share_reveal_valid_index_15() {
1201        let (circuit, instance) = make_test_data(15);
1202        let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1203        assert_eq!(prover.verify(), Ok(()));
1204    }
1205
1206    #[test]
1207    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1208    fn test_share_reveal_wrong_merkle_root() {
1209        let (circuit, mut instance) = make_test_data(0);
1210        instance.vote_comm_tree_root = pallas::Base::from(12345u64);
1211        let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1212        assert!(prover.verify().is_err());
1213    }
1214
1215    #[test]
1216    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1217    fn test_share_reveal_wrong_nullifier() {
1218        let (circuit, mut instance) = make_test_data(0);
1219        instance.share_nullifier = pallas::Base::from(99999u64);
1220        let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1221        assert!(prover.verify().is_err());
1222    }
1223
1224    #[test]
1225    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1226    fn test_share_reveal_wrong_share_index() {
1227        let (circuit, instance) = make_test_data(0);
1228        let bad_instance = Instance::from_parts(
1229            instance.share_nullifier,
1230            pallas::Base::from(999u64),
1231            pallas::Base::from(888u64),
1232            instance.proposal_id,
1233            instance.vote_decision,
1234            instance.vote_comm_tree_root,
1235            instance.voting_round_id,
1236            instance.enc_share_c1_y,
1237            instance.enc_share_c2_y,
1238        );
1239        let prover = MockProver::run(K, &circuit, vec![bad_instance.to_halo2_instance()]).unwrap();
1240        assert!(prover.verify().is_err());
1241    }
1242
1243    #[test]
1244    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1245    fn test_share_reveal_wrong_vote_decision() {
1246        let (circuit, mut instance) = make_test_data(0);
1247        instance.vote_decision = pallas::Base::from(42u64);
1248        let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1249        assert!(prover.verify().is_err());
1250    }
1251
1252    #[test]
1253    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1254    fn test_share_reveal_wrong_voting_round_id() {
1255        let (circuit, mut instance) = make_test_data(0);
1256        instance.voting_round_id = pallas::Base::from(12345u64);
1257        let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1258        assert!(prover.verify().is_err());
1259    }
1260
1261    #[test]
1262    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1263    fn test_share_reveal_cannot_replay_across_vote_commitments() {
1264        let share_idx = 0;
1265        let (circuit_a, instance_a, vote_commitment_a) = make_test_ballot(share_idx, [625; 16]);
1266        let (_circuit_b, instance_b, vote_commitment_b) = make_test_ballot(share_idx, [626; 16]);
1267
1268        assert_eq!(instance_a.voting_round_id, instance_b.voting_round_id);
1269        assert_eq!(instance_a.proposal_id, instance_b.proposal_id);
1270        assert_eq!(instance_a.vote_decision, instance_b.vote_decision);
1271        assert_ne!(vote_commitment_a, vote_commitment_b);
1272        assert_ne!(
1273            instance_a.vote_comm_tree_root,
1274            instance_b.vote_comm_tree_root
1275        );
1276
1277        let prover_a =
1278            MockProver::run(K, &circuit_a, vec![instance_a.to_halo2_instance()]).unwrap();
1279        assert_eq!(prover_a.verify(), Ok(()));
1280
1281        // Reuse ballot A's reveal witnesses, but authenticate them against
1282        // ballot B's distinct vote commitment tree root.
1283        let mut replay_instance = instance_a.clone();
1284        replay_instance.vote_comm_tree_root = instance_b.vote_comm_tree_root;
1285        let replay_prover =
1286            MockProver::run(K, &circuit_a, vec![replay_instance.to_halo2_instance()]).unwrap();
1287        assert!(replay_prover.verify().is_err());
1288    }
1289
1290    /// Proves that flipping c1_y to -c1_y (sign malleability) is detected.
1291    /// The share reveal circuit binds to the full curve point via share_commitment(blind, c1_x, c2_x, c1_y, c2_y).
1292    /// Negating c1_y changes the commitment, so the proof must fail.
1293    #[test]
1294    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1295    fn test_share_reveal_sign_flip_detected() {
1296        let (circuit, mut instance) = make_test_data(0);
1297        instance.enc_share_c1_y = -instance.enc_share_c1_y;
1298        let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1299        assert!(prover.verify().is_err());
1300    }
1301
1302    /// Tampers with share_comms[5] (a share other than the primary share at index 0).
1303    /// The share_comms are private witnesses but transitively bound to the public
1304    /// vote_comm_tree_root via:
1305    ///   share_comms → shares_hash (condition 3)
1306    ///   shares_hash → vote_commitment (condition 2)
1307    ///   vote_commitment → Merkle root (condition 1)
1308    /// Changing any share_comm alters shares_hash → vote_commitment, so the Merkle
1309    /// root computed in-circuit no longer matches the public instance root.
1310    #[test]
1311    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1312    fn test_share_reveal_tampered_share_comms_fails() {
1313        let (mut circuit, instance) = make_test_data(0);
1314
1315        // Replace share_comms[5] (index ≠ primary share index 0) with a wrong value.
1316        // Any single-field substitution propagates through shares_hash → vote_commitment
1317        // → Merkle root, invalidating condition 1.
1318        circuit.share_comms[5] = Value::known(pallas::Base::from(99999u64));
1319
1320        let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1321        // Must fail: tampered share_comm → wrong shares_hash → wrong vote_commitment
1322        // → Merkle root computed in-circuit ≠ instance.vote_comm_tree_root.
1323        assert!(prover.verify().is_err());
1324    }
1325
1326    #[test]
1327    fn share_nullifier_tracks_shares_hash_through_vote_commitment() {
1328        let voting_round_id = pallas::Base::from(42u64);
1329        let proposal_id = pallas::Base::from(7u64);
1330        let vote_decision = pallas::Base::from(1u64);
1331        let shares_hash_a = pallas::Base::from(100u64);
1332        let shares_hash_b = pallas::Base::from(101u64);
1333        let share_index = pallas::Base::from(3u64);
1334        let blind = pallas::Base::from(200u64);
1335
1336        let vote_commitment_a = compute_vote_commitment_hash(
1337            voting_round_id,
1338            shares_hash_a,
1339            proposal_id,
1340            vote_decision,
1341        );
1342        let vote_commitment_b = compute_vote_commitment_hash(
1343            voting_round_id,
1344            shares_hash_b,
1345            proposal_id,
1346            vote_decision,
1347        );
1348        assert_ne!(vote_commitment_a, vote_commitment_b);
1349
1350        let share_nullifier_a = share_nullifier_hash(vote_commitment_a, share_index, blind);
1351        let share_nullifier_b = share_nullifier_hash(vote_commitment_b, share_index, blind);
1352        assert_ne!(share_nullifier_a, share_nullifier_b);
1353
1354        assert_eq!(
1355            vote_commitment_a.to_repr(),
1356            [
1357                246, 84, 48, 178, 227, 178, 234, 71, 2, 178, 177, 211, 238, 120, 238, 157, 174, 5,
1358                29, 244, 76, 128, 250, 245, 139, 137, 84, 246, 108, 197, 47, 31,
1359            ]
1360        );
1361        assert_eq!(
1362            vote_commitment_b.to_repr(),
1363            [
1364                153, 178, 215, 171, 108, 162, 193, 164, 62, 112, 205, 83, 186, 133, 99, 176, 44,
1365                202, 218, 73, 114, 189, 204, 58, 82, 13, 52, 188, 69, 70, 131, 3,
1366            ]
1367        );
1368        assert_eq!(
1369            share_nullifier_a.to_repr(),
1370            [
1371                119, 176, 211, 29, 114, 129, 188, 150, 122, 163, 222, 136, 21, 250, 159, 126, 139,
1372                224, 205, 109, 60, 84, 112, 66, 101, 139, 161, 62, 127, 17, 37, 22,
1373            ]
1374        );
1375        assert_eq!(
1376            share_nullifier_b.to_repr(),
1377            [
1378                244, 6, 225, 7, 34, 104, 123, 192, 48, 94, 4, 222, 156, 224, 137, 204, 121, 90, 18,
1379                186, 234, 235, 223, 30, 101, 75, 79, 249, 44, 11, 24, 59,
1380            ]
1381        );
1382    }
1383
1384    #[test]
1385    fn share_nullifier_hash_frozen_vector() {
1386        assert_eq!(
1387            share_nullifier_hash(
1388                pallas::Base::from(42u64),
1389                pallas::Base::from(3u64),
1390                pallas::Base::from(200u64),
1391            ),
1392            pallas::Base::from_repr([
1393                103, 140, 231, 81, 182, 191, 8, 141, 126, 173, 35, 129, 94, 244, 230, 146, 27, 161,
1394                255, 223, 211, 230, 26, 212, 86, 62, 15, 167, 99, 237, 233, 63,
1395            ])
1396            .expect("frozen vector must be canonical")
1397        );
1398    }
1399
1400    #[test]
1401    fn test_share_reveal_domain_tag_matches_server() {
1402        assert_eq!(domain_tag_share_spend(), crate::domain_tags::share_spend());
1403    }
1404
1405    /// Measures actual rows used by the share-reveal circuit via `CircuitCost::measure`.
1406    ///
1407    /// `CircuitCost` runs the floor planner against the circuit and tracks the
1408    /// highest row offset assigned in any column, giving the real "rows consumed"
1409    /// number rather than the theoretical 2^K capacity.
1410    ///
1411    /// Run with:
1412    ///   cargo test row_budget -- --nocapture --ignored
1413    #[test]
1414    #[ignore = "long-running row-budget diagnostic; run with `cargo test row_budget -- --ignored --nocapture`"]
1415    fn row_budget() {
1416        use halo2_proofs::dev::CircuitCost;
1417        use pasta_curves::vesta;
1418        use std::println;
1419
1420        let (circuit, _) = make_test_data(0);
1421
1422        let cost = CircuitCost::<vesta::Point, _>::measure(K, &circuit);
1423        let debug = format!("{cost:?}");
1424
1425        let extract = |field: &str| -> usize {
1426            let prefix = format!("{field}: ");
1427            debug
1428                .split(&prefix)
1429                .nth(1)
1430                .and_then(|s| s.split([',', ' ', '}']).next())
1431                .and_then(|n| n.parse().ok())
1432                .unwrap_or(0)
1433        };
1434
1435        let max_rows = extract("max_rows");
1436        let max_advice_rows = extract("max_advice_rows");
1437        let max_fixed_rows = extract("max_fixed_rows");
1438        let total_available = 1usize << K;
1439
1440        println!("=== share-reveal circuit row budget (K={K}) ===");
1441        println!("  max_rows (floor-planner high-water mark): {max_rows}");
1442        println!("  max_advice_rows:                          {max_advice_rows}");
1443        println!("  max_fixed_rows:                           {max_fixed_rows}");
1444        println!("  2^K  (total available rows):              {total_available}");
1445        println!(
1446            "  headroom:                                 {}",
1447            total_available.saturating_sub(max_rows)
1448        );
1449        println!(
1450            "  utilisation:                              {:.1}%",
1451            100.0 * max_rows as f64 / total_available as f64
1452        );
1453        println!();
1454        println!("  Full debug: {debug}");
1455
1456        // Witness-independence check: Circuit::default() (all unknowns)
1457        // must produce exactly the same layout as the filled circuit.
1458        let cost_default = CircuitCost::<vesta::Point, _>::measure(K, &Circuit::default());
1459        let debug_default = format!("{cost_default:?}");
1460        let max_rows_default = debug_default
1461            .split("max_rows: ")
1462            .nth(1)
1463            .and_then(|s| s.split([',', ' ', '}']).next())
1464            .and_then(|n| n.parse::<usize>().ok())
1465            .unwrap_or(0);
1466        if max_rows_default == max_rows {
1467            println!(
1468                "  Witness-independence: PASS \
1469                (Circuit::default() max_rows={max_rows_default} == filled max_rows={max_rows})"
1470            );
1471        } else {
1472            println!(
1473                "  Witness-independence: FAIL \
1474                (Circuit::default() max_rows={max_rows_default} != filled max_rows={max_rows}) \
1475                — row count depends on witness values!"
1476            );
1477        }
1478
1479        println!("  VOTE_COMM_TREE_DEPTH (circuit constant): {VOTE_COMM_TREE_DEPTH}");
1480
1481        // Minimum-K probe: find the smallest K at which MockProver passes.
1482        for probe_k in 11u32..=K {
1483            let (c, inst) = make_test_data(0);
1484            match MockProver::run(probe_k, &c, vec![inst.to_halo2_instance()]) {
1485                Err(_) => {
1486                    println!("  K={probe_k}: not enough rows (synthesizer rejected)");
1487                    continue;
1488                }
1489                Ok(p) => match p.verify() {
1490                    Ok(()) => {
1491                        println!("  Minimum viable K: {probe_k} (2^{probe_k} = {} rows, {:.1}% headroom)",
1492                            1usize << probe_k,
1493                            100.0 * (1.0 - max_rows as f64 / (1usize << probe_k) as f64));
1494                        break;
1495                    }
1496                    Err(_) => println!("  K={probe_k}: too small"),
1497                },
1498            }
1499        }
1500    }
1501}