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}