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