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