voting_circuits/vote_proof/circuit.rs
1//! The Vote Proof circuit implementation (ZKP #2).
2//!
3//! Proves that a registered voter is casting a valid vote, without
4//! revealing which VAN they hold. Currently implements:
5//!
6//! - **Condition 1**: VAN Membership (Poseidon Merkle path, `constrain_instance`).
7//! - **Condition 2**: VAN Integrity (Poseidon hash).
8//! - **Condition 3**: Diversified Address Integrity (`vpk_pk_d = [ivk_v] * vpk_g_d` via CommitIvk).
9//! - **Condition 4**: Spend Authority — `r_vpk = vsk.ak + [alpha_v] * G` (fixed-base mul + point add, `constrain_instance`).
10//! - **Condition 5**: VAN Nullifier Integrity (nested Poseidon, `constrain_instance`).
11//! - **Condition 6**: Proposal Authority Decrement (AddChip + range check).
12//! - **Condition 7**: New VAN Integrity (Poseidon hash, `constrain_instance`).
13//! - **Condition 8**: Shares Sum Correctness (AddChip, `constrain_equal`).
14//! - **Condition 9**: Shares Range (LookupRangeCheck, `[0, 2^30)`).
15//! - **Condition 10**: Shares Hash Integrity (Poseidon `ConstantLength<16>` over 16 blinded share commitments; output flows to condition 12).
16//! - **Condition 11**: Encryption Integrity (ECC variable-base mul, `constrain_equal`).
17//! - **Condition 12**: Vote Commitment Integrity (Poseidon `ConstantLength<5>`, `constrain_instance`).
18//!
19//! Conditions 1–4 and 5–12 are fully constrained in-circuit.
20//!
21//! ## Conditions overview
22//!
23//! VAN ownership and spending:
24//! - **Condition 1**: VAN Membership — Merkle path from `vote_authority_note_old`
25//! to `vote_comm_tree_root`.
26//! - **Condition 2**: VAN Integrity — `vote_authority_note_old` is the two-layer
27//! Poseidon hash (ZKP 1–compatible: core then finalize with rand). *(implemented)*
28//! - **Condition 3**: Diversified Address Integrity — `vpk_pk_d = [ivk_v] * vpk_g_d`
29//! where `ivk_v = CommitIvk(ExtractP([vsk]*SpendAuthG), vsk.nk)`. *(implemented)*
30//! - **Condition 4**: Spend Authority — `r_vpk = vsk.ak + [alpha_v] * G`; enforced in-circuit (fixed-base mul + point add, `constrain_instance`).
31//! - **Condition 5**: VAN Nullifier Integrity — `van_nullifier` is correctly
32//! derived from `vsk.nk`. *(implemented)*
33//!
34//! New VAN construction:
35//! - **Condition 6**: Proposal Authority Decrement — `proposal_authority_new =
36//! proposal_authority_old - (1 << proposal_id)`, with bitmask range [0, 2^16). *(implemented)*
37//! - **Condition 7**: New VAN Integrity — same two-layer structure as condition 2
38//! but with decremented authority. *(implemented)*
39//!
40//! Vote commitment construction:
41//! - **Condition 8**: Shares Sum Correctness — `sum(shares_1..16) = total_note_value`.
42//! *(implemented)*
43//! - **Condition 9**: Shares Range — each `shares_j` in `[0, 2^30)`.
44//! *(implemented)*
45//! - **Condition 10**: Shares Hash Integrity — `shares_hash = H(enc_share_1..16)`.
46//! *(implemented)*
47//! - **Condition 11**: Encryption Integrity — each `enc_share_i = ElGamal(shares_i, r_i, ea_pk)`.
48//! *(implemented)*
49//! - **Condition 12**: Vote Commitment Integrity — `vote_commitment = H(DOMAIN_VC, voting_round_id,
50//! shares_hash, proposal_id, vote_decision)`. *(implemented)*
51
52use alloc::vec::Vec;
53
54
55use halo2_proofs::{
56 circuit::{AssignedCell, Layouter, Value, floor_planner},
57 plonk::{self, Advice, Column, ConstraintSystem, Fixed, Instance as InstanceColumn},
58};
59use pasta_curves::{pallas, vesta};
60
61use halo2_gadgets::{
62 ecc::{
63 chip::{EccChip, EccConfig},
64 NonIdentityPoint, ScalarFixed,
65 },
66 poseidon::{
67 primitives::{self as poseidon, ConstantLength},
68 Hash as PoseidonHash, Pow5Chip as PoseidonChip, Pow5Config as PoseidonConfig,
69 },
70 sinsemilla::chip::{SinsemillaChip, SinsemillaConfig},
71 utilities::lookup_range_check::{LookupRangeCheck, LookupRangeCheckConfig},
72};
73use crate::circuit::address_ownership::{prove_address_ownership, spend_auth_g_mul};
74use crate::circuit::elgamal::{EaPkInstanceLoc, prove_elgamal_encryptions};
75use crate::circuit::poseidon_merkle::{MerkleSwapGate, synthesize_poseidon_merkle_path};
76use orchard::circuit::commit_ivk::{CommitIvkChip, CommitIvkConfig};
77use orchard::circuit::gadget::{add_chip::{AddChip, AddConfig}, assign_free_advice, AddInstruction};
78use orchard::constants::{
79 OrchardCommitDomains, OrchardFixedBases, OrchardHashDomains,
80};
81use crate::circuit::van_integrity;
82use crate::circuit::vote_commitment;
83use crate::shares_hash::compute_shares_hash_in_circuit;
84#[cfg(test)]
85use crate::shares_hash::hash_share_commitment_in_circuit;
86use super::authority_decrement::{AuthorityDecrementChip, AuthorityDecrementConfig};
87
88// ================================================================
89// Constants
90// ================================================================
91
92/// Depth of the Poseidon-based vote commitment tree.
93///
94/// Reduced from Zcash's depth 32 (~4.3B) because governance voting
95/// produces far fewer leaves than a full shielded pool. Each voter
96/// generates 1 leaf per delegation + 2 per vote, so even 10K voters
97/// × 50 proposals ≈ 1M leaves — well within 2^24 ≈ 16.7M capacity.
98///
99/// Must match `vote_commitment_tree::TREE_DEPTH`.
100pub const VOTE_COMM_TREE_DEPTH: usize = 24;
101
102/// Circuit size (2^K rows).
103///
104/// K=14 (16,384 rows). `CircuitCost::measure` reports a floor-planner
105/// high-water mark of **3,512 rows** (21% of 16,384). The `V1` floor planner
106/// packs non-overlapping regions into the same row range across different
107/// columns, so the high-water mark is much lower than a naive sum-of-heights
108/// estimate.
109///
110/// Key contributors (rough per-region heights, not per-column sums):
111/// - 24-level Merkle path: 24 Poseidon regions stacked sequentially — the
112/// tallest single stack in the circuit.
113/// - ECC fixed- and variable-base multiplications packed alongside the
114/// Poseidon regions in non-overlapping columns.
115/// - 10-bit Sinsemilla/range-check lookup table: 1,024 fixed rows.
116///
117/// The `[v_i]*G` term uses `FixedPointShort` (22-window short-scalar path)
118/// rather than `FixedPointBaseField` (85-window full-scalar path), saving
119/// 315 rows (3,827 → 3,512 measured). Run the `row_budget` benchmark to
120/// re-measure after circuit changes:
121/// `cargo test --features vote-proof row_budget -- --nocapture --ignored`
122pub const K: u32 = 13;
123
124pub use van_integrity::DOMAIN_VAN;
125pub use vote_commitment::DOMAIN_VC;
126
127/// Maximum proposal_id bit index (exclusive upper bound). `proposal_id` is in `[1, MAX_PROPOSAL_ID)`,
128/// i.e. valid values are 1–15. Bit 0 is permanently reserved as the sentinel/unset value and is
129/// rejected by the non-zero gate in `AuthorityDecrementChip` (`q_cond_6`). This means a voting
130/// round supports at most 15 proposals, not 16.
131/// Spec: "The number of proposals for a polling session must be <= 16."
132///
133/// # Indexing Convention
134///
135/// `proposal_id` is **1-indexed** throughout the entire stack:
136///
137/// - **On-chain (`MsgCreateVotingSession`)**: proposals carry `Id = 1, 2, …, N`.
138/// - **On-chain (`ValidateProposalId`)**: rejects `proposal_id < 1`.
139/// - **Circuit (this file)**: `proposal_id` serves as the bit-position in the
140/// 16-bit `proposal_authority` bitmask. The `proposal_id != 0` gate ensures
141/// bit 0 is never selected, so the effective bit range is `[1, 15]`.
142/// - **Client (`zcash_voting::zkp2`)**: validates `proposal_id` in `[1, 15]`
143/// before building the proof.
144///
145/// Bit 0 of `proposal_authority` is always set (initial value `0xFFFF`) and
146/// never decremented, acting as a structural invariant rather than a usable slot.
147pub const MAX_PROPOSAL_ID: usize = 16;
148
149// ================================================================
150// Public input offsets (11 field elements).
151// ================================================================
152
153/// Public input offset for the VAN nullifier (prevents double-vote).
154const VAN_NULLIFIER: usize = 0;
155/// Public input offset for the randomized voting public key (condition 4: Spend Authority).
156/// x-coordinate of r_vpk = vsk.ak + [alpha_v] * G.
157const R_VPK_X: usize = 1;
158/// Public input offset for r_vpk y-coordinate.
159const R_VPK_Y: usize = 2;
160/// Public input offset for the new VAN commitment (with decremented authority).
161const VOTE_AUTHORITY_NOTE_NEW: usize = 3;
162/// Public input offset for the vote commitment hash.
163const VOTE_COMMITMENT: usize = 4;
164/// Public input offset for the vote commitment tree root.
165const VOTE_COMM_TREE_ROOT: usize = 5;
166/// Public input offset for the tree anchor height.
167const VOTE_COMM_TREE_ANCHOR_HEIGHT: usize = 6;
168/// Public input offset for the proposal identifier.
169const PROPOSAL_ID: usize = 7;
170/// Public input offset for the voting round identifier.
171const VOTING_ROUND_ID: usize = 8;
172/// Public input offset for the election authority public key x-coordinate.
173const EA_PK_X: usize = 9;
174/// Public input offset for the election authority public key y-coordinate.
175const EA_PK_Y: usize = 10;
176
177// Suppress dead-code warnings for public input offsets that are
178// defined but not yet used by any condition's constraint logic.
179// VOTE_COMM_TREE_ANCHOR_HEIGHT is validated out-of-circuit by the chain's
180// ante handler: sdk/x/vote/ante/validate.go calls GetCommitmentRootAtHeight
181// with msg.VoteCommTreeAnchorHeight and rejects the transaction if no root
182// exists at that height (ErrInvalidAnchorHeight). The retrieved root is then
183// passed as the VoteCommTreeRoot public input to the ZKP verifier, which the
184// circuit constrains via constrain_instance. This binds the anchor height to
185// the in-circuit tree root, mirroring Zcash's out-of-circuit anchor design.
186const _: usize = VOTE_COMM_TREE_ANCHOR_HEIGHT;
187
188// ================================================================
189// Out-of-circuit helpers
190// ================================================================
191
192pub use van_integrity::van_integrity_hash;
193pub use vote_commitment::vote_commitment_hash;
194
195/// Returns the domain separator for the VAN nullifier inner hash.
196///
197/// Encodes `"vote authority spend"` as a Pallas base field element
198/// by interpreting the UTF-8 bytes as a little-endian 256-bit integer.
199/// This domain tag differentiates VAN nullifier derivation from other
200/// Poseidon uses in the protocol.
201pub fn domain_van_nullifier() -> pallas::Base {
202 // "vote authority spend" (20 bytes) zero-padded to 32, as LE u64 words.
203 pallas::Base::from_raw([
204 0x7475_6120_6574_6f76, // b"vote aut" LE
205 0x7320_7974_6972_6f68, // b"hority s" LE
206 0x0000_0000_646e_6570, // b"pend\0\0\0\0" LE
207 0,
208 ])
209}
210
211/// Out-of-circuit VAN nullifier hash (condition 5).
212///
213/// ```text
214/// van_nullifier = Poseidon(vsk_nk, domain_tag, voting_round_id, vote_authority_note_old)
215/// ```
216///
217/// Single `ConstantLength<4>` call (2 permutations at rate=2).
218/// Used by the builder and tests to compute the expected VAN nullifier.
219pub fn van_nullifier_hash(
220 vsk_nk: pallas::Base,
221 voting_round_id: pallas::Base,
222 vote_authority_note_old: pallas::Base,
223) -> pallas::Base {
224 poseidon::Hash::<_, poseidon::P128Pow5T3, ConstantLength<4>, 3, 2>::init().hash([
225 vsk_nk,
226 domain_van_nullifier(),
227 voting_round_id,
228 vote_authority_note_old,
229 ])
230}
231
232/// Out-of-circuit Poseidon hash of two field elements.
233///
234/// `Poseidon(a, b)` with P128Pow5T3, ConstantLength<2>, width 3, rate 2.
235/// Used for Merkle path computation (condition 1) and tests. This is the
236/// same hash function used by `vote_commitment_tree::MerkleHashVote::combine`.
237pub fn poseidon_hash_2(a: pallas::Base, b: pallas::Base) -> pallas::Base {
238 poseidon::Hash::<_, poseidon::P128Pow5T3, ConstantLength<2>, 3, 2>::init().hash([a, b])
239}
240
241/// Out-of-circuit per-share blinded commitment (condition 10).
242///
243/// Computes `Poseidon(blind, c1_x, c2_x, c1_y, c2_y)` for a single share.
244///
245/// The y-coordinates bind the commitment to the exact curve point, not just
246/// the x-coordinate. Without them, an attacker can negate the ElGamal
247/// ciphertext (flip sign bits) without invalidating the ZKP — corrupting
248/// the homomorphic tally. See: ciphertext sign-malleability fix.
249///
250/// The blind factor prevents anyone who sees the encrypted shares on-chain
251/// from recomputing shares_hash and linking it to a specific vote commitment.
252pub fn share_commitment(
253 blind: pallas::Base,
254 c1_x: pallas::Base,
255 c2_x: pallas::Base,
256 c1_y: pallas::Base,
257 c2_y: pallas::Base,
258) -> pallas::Base {
259 poseidon::Hash::<_, poseidon::P128Pow5T3, ConstantLength<5>, 3, 2>::init()
260 .hash([blind, c1_x, c2_x, c1_y, c2_y])
261}
262
263/// Out-of-circuit shares hash (condition 10).
264///
265/// Computes blinded per-share commitments and hashes them together:
266/// ```text
267/// share_comm_i = Poseidon(blind_i, c1_i_x, c2_i_x, c1_i_y, c2_i_y) for i in 0..16
268/// shares_hash = Poseidon(share_comm_0, ..., share_comm_15)
269/// ```
270///
271/// The blind factors prevent anyone who sees the encrypted shares on-chain
272/// from recomputing shares_hash and linking it to a specific vote commitment.
273///
274/// Used by the builder and tests to compute the expected shares hash.
275pub fn shares_hash(
276 share_blinds: [pallas::Base; 16],
277 enc_share_c1_x: [pallas::Base; 16],
278 enc_share_c2_x: [pallas::Base; 16],
279 enc_share_c1_y: [pallas::Base; 16],
280 enc_share_c2_y: [pallas::Base; 16],
281) -> pallas::Base {
282 let comms: [pallas::Base; 16] = core::array::from_fn(|i| {
283 share_commitment(
284 share_blinds[i],
285 enc_share_c1_x[i],
286 enc_share_c2_x[i],
287 enc_share_c1_y[i],
288 enc_share_c2_y[i],
289 )
290 });
291 poseidon::Hash::<_, poseidon::P128Pow5T3, ConstantLength<16>, 3, 2>::init().hash(comms)
292}
293
294// ================================================================
295// Config
296// ================================================================
297
298/// Configuration for the Vote Proof circuit.
299///
300/// Holds chip configs for Poseidon (conditions 1, 2, 5, 7, 10), AddChip
301/// (conditions 6, 8), LookupRangeCheck (conditions 6, 9), ECC
302/// (conditions 3, 11), and the Merkle swap gate (condition 1).
303#[derive(Clone, Debug)]
304pub struct Config {
305 /// Public input column (9 field elements).
306 primary: Column<InstanceColumn>,
307 /// 10 advice columns for private witness data.
308 ///
309 /// Column layout follows the delegation circuit for consistency:
310 /// - `advices[0..5]`: general witness assignment + Merkle swap gate.
311 /// - `advices[5]`: Poseidon partial S-box column.
312 /// - `advices[6..9]`: Poseidon state columns + AddChip output.
313 /// - `advices[9]`: range check running sum.
314 advices: [Column<Advice>; 10],
315 /// Poseidon hash chip configuration.
316 ///
317 /// P128Pow5T3 with width 3, rate 2. Used for VAN integrity (condition 2),
318 /// VAN nullifier (condition 5), new VAN integrity (condition 7),
319 /// vote commitment Merkle path (condition 1), and vote commitment
320 /// integrity (conditions 10, 12).
321 poseidon_config: PoseidonConfig<pallas::Base, 3, 2>,
322 /// AddChip: constrains `a + b = c` on a single row.
323 ///
324 /// Uses advices[7] (a), advices[8] (b), advices[6] (c), matching
325 /// the delegation circuit's column assignment.
326 /// Used in conditions 6 (proposal authority decrement) and 8 (shares
327 /// sum correctness).
328 add_config: AddConfig,
329 /// ECC chip configuration (condition 3: diversified address integrity, condition 11: El Gamal).
330 ///
331 /// Condition 3 proves `vpk_pk_d = [ivk_v] * vpk_g_d` via the CommitIvk chain:
332 /// `[vsk] * SpendAuthG → ak → CommitIvk(ExtractP(ak), nk, rivk_v) → ivk_v → [ivk_v] * vpk_g_d`.
333 /// Shares advice and fixed columns with Poseidon per delegation layout.
334 ecc_config: EccConfig<OrchardFixedBases>,
335 /// Sinsemilla chip configuration (condition 3: CommitIvk requires Sinsemilla).
336 ///
337 /// Uses advices[0..5] for Sinsemilla message hashing, advices[6] for
338 /// witnessing message pieces, and lagrange_coeffs[0] for the fixed y_Q column.
339 /// Also loads the 10-bit lookup table used by LookupRangeCheckConfig.
340 sinsemilla_config:
341 SinsemillaConfig<OrchardHashDomains, OrchardCommitDomains, OrchardFixedBases>,
342 /// CommitIvk chip configuration (condition 3: canonicity checks on ak || nk).
343 ///
344 /// Provides the custom gate and decomposition logic for the
345 /// Sinsemilla-based `CommitIvk` commitment.
346 commit_ivk_config: CommitIvkConfig,
347 /// 10-bit lookup range check configuration.
348 ///
349 /// Uses advices[9] as the running-sum column. Each word is 10 bits,
350 /// so `num_words` × 10 gives the total bit-width checked.
351 /// Used in condition 6 to ensure authority values and diff are in [0, 2^16)
352 /// (16-bit bitmask), and condition 9 to ensure each share is in `[0, 2^30)`.
353 range_check: LookupRangeCheckConfig<pallas::Base, 10>,
354 /// Merkle conditional swap gate (condition 1).
355 ///
356 /// At each of the 24 Merkle tree levels, conditionally swaps
357 /// (current, sibling) into (left, right) based on the position bit.
358 /// Uses advices[0..5]: pos_bit, current, sibling, left, right.
359 merkle_swap: MerkleSwapGate,
360 /// Configuration for condition 6 (Proposal Authority Decrement).
361 authority_decrement: AuthorityDecrementConfig,
362}
363
364impl Config {
365 /// Constructs a Poseidon chip from this configuration.
366 ///
367 /// Width 3 (P128Pow5T3 state size), rate 2 (absorbs 2 field elements
368 /// per permutation — halves the number of rounds vs rate 1).
369 pub(crate) fn poseidon_chip(&self) -> PoseidonChip<pallas::Base, 3, 2> {
370 PoseidonChip::construct(self.poseidon_config.clone())
371 }
372
373 /// Constructs an AddChip for field element addition (`c = a + b`).
374 fn add_chip(&self) -> AddChip {
375 AddChip::construct(self.add_config.clone())
376 }
377
378 /// Constructs an ECC chip for curve operations (conditions 3, 11).
379 fn ecc_chip(&self) -> EccChip<OrchardFixedBases> {
380 EccChip::construct(self.ecc_config.clone())
381 }
382
383 /// Constructs a Sinsemilla chip (condition 3: CommitIvk).
384 fn sinsemilla_chip(
385 &self,
386 ) -> SinsemillaChip<OrchardHashDomains, OrchardCommitDomains, OrchardFixedBases> {
387 SinsemillaChip::construct(self.sinsemilla_config.clone())
388 }
389
390 /// Constructs a CommitIvk chip for canonicity checks (condition 3).
391 fn commit_ivk_chip(&self) -> CommitIvkChip {
392 CommitIvkChip::construct(self.commit_ivk_config.clone())
393 }
394
395 /// Returns the range check configuration (10-bit words).
396 fn range_check_config(&self) -> LookupRangeCheckConfig<pallas::Base, 10> {
397 self.range_check
398 }
399}
400
401// ================================================================
402// Circuit
403// ================================================================
404
405/// The Vote Proof circuit (ZKP #2).
406///
407/// Proves that a registered voter is casting a valid vote, without
408/// revealing which VAN they hold. Contains witness fields for all
409/// 12 conditions (condition 4 enforced out-of-circuit); constraint logic is added incrementally.
410///
411/// Conditions 1–3 and 5–12 are fully constrained in-circuit; condition 4 (Spend Authority) is
412/// enforced out-of-circuit via signature verification.
413#[derive(Clone, Debug, Default)]
414pub struct Circuit {
415 // === VAN ownership and spending (conditions 1–5; condition 4 out-of-circuit) ===
416
417 // Condition 1 (VAN Membership): Poseidon-based Merkle path from
418 // vote_authority_note_old to vote_comm_tree_root.
419 /// Merkle authentication path (sibling hashes at each tree level).
420 pub(crate) vote_comm_tree_path: Value<[pallas::Base; VOTE_COMM_TREE_DEPTH]>,
421 /// Leaf position in the vote commitment tree.
422 pub(crate) vote_comm_tree_position: Value<u32>,
423
424 // Condition 2 (VAN Integrity): two-layer hash matching ZKP 1 (delegation):
425 // van_comm_core = Poseidon(DOMAIN_VAN, vpk_g_d.x, vpk_pk_d.x, total_note_value,
426 // voting_round_id, proposal_authority_old);
427 // vote_authority_note_old = Poseidon(van_comm_core, van_comm_rand).
428 //
429 // Condition 3 (Diversified Address Integrity): vpk_pk_d = [ivk_v] * vpk_g_d
430 // where ivk_v = CommitIvk(ExtractP([vsk]*SpendAuthG), vsk.nk, rivk_v).
431 // Full affine points are needed for condition 3's ECC operations;
432 // x-coordinates are extracted in-circuit for Poseidon hashing (conditions 2, 7).
433 /// Voting public key — diversified base point (from DiversifyHash(d)).
434 /// This is the vpk_g_d component of the voting hotkey address.
435 /// Condition 3 performs `[ivk_v] * vpk_g_d` to derive vpk_pk_d.
436 pub(crate) vpk_g_d: Value<pallas::Affine>,
437 /// Voting public key — diversified transmission key (pk_d = [ivk_v] * g_d).
438 /// This is the vpk_pk_d component of the voting hotkey address.
439 /// Condition 3 (Diversified Address Integrity) constrains this to equal `[ivk_v] * vpk_g_d`.
440 pub(crate) vpk_pk_d: Value<pallas::Affine>,
441 /// The voter's total delegated weight, denominated in ballots
442 /// (1 ballot = 0.125 ZEC; converted from zatoshi by ZKP #1 condition 8).
443 pub(crate) total_note_value: Value<pallas::Base>,
444 // Condition 6:
445 /// Remaining proposal authority bitmask in the old VAN.
446 pub(crate) proposal_authority_old: Value<pallas::Base>,
447 /// Blinding randomness for the VAN commitment.
448 pub(crate) van_comm_rand: Value<pallas::Base>,
449 /// The old VAN commitment (Poseidon hash output). Used as the Merkle
450 /// leaf in condition 1 and constrained to equal the derived hash here.
451 pub(crate) vote_authority_note_old: Value<pallas::Base>,
452
453 // Condition 3 (Diversified Address Integrity): prover controls the VAN address.
454 // vpk_pk_d = [ivk_v] * vpk_g_d
455 // where ivk_v = CommitIvk_rivk_v(ExtractP([vsk]*SpendAuthG), vsk.nk)
456 /// Voting spending key (scalar for ECC multiplication).
457 /// Used in condition 3 for `[vsk] * SpendAuthG`.
458 pub(crate) vsk: Value<pallas::Scalar>,
459 /// CommitIvk randomness for the ivk_v derivation (condition 3).
460 /// Used as the blinding scalar in `CommitIvk(ak, nk, rivk_v)`.
461 pub(crate) rivk_v: Value<pallas::Scalar>,
462 /// Spend auth randomizer for condition 4: r_vpk = vsk.ak + [alpha_v] * G.
463 pub(crate) alpha_v: Value<pallas::Scalar>,
464
465 // Condition 5 (VAN Nullifier Integrity): nullifier deriving key.
466 // Also used in condition 3 as the nk input to CommitIvk.
467 /// Nullifier deriving key derived from vsk.
468 pub(crate) vsk_nk: Value<pallas::Base>,
469
470 // Condition 6 (Proposal Authority Decrement): one_shifted = 2^proposal_id.
471 /// `2^proposal_id`, supplied as a private witness and constrained by a lookup.
472 ///
473 /// Field arithmetic cannot express variable-exponent exponentiation as a
474 /// polynomial gate, so the prover witnesses `one_shifted` directly. The lookup
475 /// table `(0,1), (1,2), ..., (15,32768)` then proves `one_shifted == 2^proposal_id`.
476 /// The bit-decomposition region uses this value to compute
477 /// `proposal_authority_new = proposal_authority_old - one_shifted`.
478 pub(crate) one_shifted: Value<pallas::Base>,
479
480 // === Vote commitment construction (conditions 8–12) ===
481
482 // Condition 8 (Shares Sum): sum(shares_1..16) = total_note_value.
483 // Condition 9 (Shares Range): each share in [0, 2^30).
484 /// Voting share vector (16 random shares that sum to total_note_value).
485 /// The decomposition is chosen by the prover for amount privacy: the
486 /// on-chain El Gamal ciphertexts reveal no weight fingerprint.
487 pub(crate) shares: [Value<pallas::Base>; 16],
488
489 // Condition 10 (Shares Hash Integrity): El Gamal ciphertext coordinates.
490 // These are the coordinates of the curve points comprising each
491 // El Gamal ciphertext. Condition 11 constrains these to be correct
492 // encryptions; condition 10 hashes them (including y-coordinates to
493 // prevent ciphertext sign-malleability).
494 /// X-coordinates of C1_i = r_i * G for each share (via ExtractP).
495 pub(crate) enc_share_c1_x: [Value<pallas::Base>; 16],
496 /// X-coordinates of C2_i = shares_i * G + r_i * ea_pk for each share (via ExtractP).
497 pub(crate) enc_share_c2_x: [Value<pallas::Base>; 16],
498 /// Y-coordinates of C1_i (bound to the exact curve point, preventing sign-malleability).
499 pub(crate) enc_share_c1_y: [Value<pallas::Base>; 16],
500 /// Y-coordinates of C2_i (bound to the exact curve point, preventing sign-malleability).
501 pub(crate) enc_share_c2_y: [Value<pallas::Base>; 16],
502
503 // Condition 10 (Shares Hash Integrity): per-share blind factors for blinded commitments.
504 /// Random blind factors: share_comm_i = Poseidon(blind_i, c1_i_x, c2_i_x, c1_i_y, c2_i_y).
505 pub(crate) share_blinds: [Value<pallas::Base>; 16],
506
507 // Condition 11 (Encryption Integrity): El Gamal randomness and public key.
508 /// El Gamal encryption randomness for each share (base field element,
509 /// converted to scalar via ScalarVar::from_base in-circuit).
510 pub(crate) share_randomness: [Value<pallas::Base>; 16],
511 /// Election authority public key (Pallas curve point).
512 /// The El Gamal encryption key — published as a round parameter.
513 /// Both coordinates are public inputs (EA_PK_X, EA_PK_Y).
514 pub(crate) ea_pk: Value<pallas::Affine>,
515
516 // Condition 12 (Vote Commitment Integrity): vote decision.
517 /// The voter's choice (hidden inside the vote commitment).
518 pub(crate) vote_decision: Value<pallas::Base>,
519}
520
521impl Circuit {
522 /// Creates a circuit with conditions 1–3 and 5–7 witnesses populated.
523 ///
524 /// All other witness fields are set to `Value::unknown()`.
525 /// - Condition 1 uses `vote_authority_note_old` as the Merkle leaf,
526 /// with `vote_comm_tree_path` and `vote_comm_tree_position` for
527 /// the authentication path.
528 /// - Condition 2 binds `vote_authority_note_old` to the Poseidon hash
529 /// of its components (using x-coordinates extracted from vpk_g_d, vpk_pk_d).
530 /// - Condition 3 proves diversified address integrity via CommitIvk chain:
531 /// `[vsk] * SpendAuthG → ak → CommitIvk(ak, nk, rivk_v) → ivk_v → [ivk_v] * vpk_g_d = vpk_pk_d`.
532 /// - Condition 5 reuses `vote_authority_note_old` and `voting_round_id`.
533 /// - Condition 6 derives `proposal_authority_new` from
534 /// `proposal_authority_old`.
535 /// - Condition 7 reuses all condition 2 witnesses except
536 /// `proposal_authority_old`, which is replaced by the
537 /// in-circuit `proposal_authority_new` from condition 6.
538 pub fn with_van_witnesses(
539 vote_comm_tree_path: Value<[pallas::Base; VOTE_COMM_TREE_DEPTH]>,
540 vote_comm_tree_position: Value<u32>,
541 vpk_g_d: Value<pallas::Affine>,
542 vpk_pk_d: Value<pallas::Affine>,
543 total_note_value: Value<pallas::Base>,
544 proposal_authority_old: Value<pallas::Base>,
545 van_comm_rand: Value<pallas::Base>,
546 vote_authority_note_old: Value<pallas::Base>,
547 vsk: Value<pallas::Scalar>,
548 rivk_v: Value<pallas::Scalar>,
549 vsk_nk: Value<pallas::Base>,
550 alpha_v: Value<pallas::Scalar>,
551 ) -> Self {
552 Circuit {
553 vote_comm_tree_path,
554 vote_comm_tree_position,
555 vpk_g_d,
556 vpk_pk_d,
557 total_note_value,
558 proposal_authority_old,
559 van_comm_rand,
560 vote_authority_note_old,
561 vsk,
562 rivk_v,
563 alpha_v,
564 vsk_nk,
565 ..Default::default()
566 }
567 }
568}
569
570/// In-circuit Poseidon hash for one share commitment: `Poseidon(blind, c1_x, c2_x, c1_y, c2_y)`.
571///
572/// Uses the same parameters as the out-of-circuit [`share_commitment`] (P128Pow5T3,
573/// ConstantLength<5>, width 3, rate 2) so that native and in-circuit hashes match.
574
575impl plonk::Circuit<pallas::Base> for Circuit {
576 type Config = Config;
577 type FloorPlanner = floor_planner::V1;
578
579 fn without_witnesses(&self) -> Self {
580 Self::default()
581 }
582
583 fn configure(meta: &mut ConstraintSystem<pallas::Base>) -> Self::Config {
584 // 10 advice columns, matching the delegation circuit layout so the two
585 // circuits share the same column assignment and chip configurations.
586 // The count is driven by the ECC chip, which is the largest consumer
587 // and requires all 10 columns for its internal scalar-multiplication
588 // gates. The remaining chips tile within that same 10-column window:
589 //
590 // advices[0..5] — general witness assignment, Sinsemilla pair 1
591 // message columns, and the Merkle swap gate
592 // (pos_bit / current / sibling / left / right).
593 // advices[5] — Poseidon partial S-box column; also the start of
594 // Sinsemilla pair 2 main columns (advices[5..10]).
595 // advices[6..9] — Poseidon width-3 state columns; AddChip uses these
596 // same three columns (a=advices[7], b=advices[8],
597 // c=advices[6]).
598 // advices[9] — LookupRangeCheck running-sum column.
599 let advices: [Column<Advice>; 10] = core::array::from_fn(|_| meta.advice_column());
600 for col in &advices {
601 meta.enable_equality(*col);
602 }
603
604 // Instance column for public inputs.
605 let primary = meta.instance_column();
606 meta.enable_equality(primary);
607
608 // 8 fixed columns shared between ECC and Poseidon chips.
609 // Indices 0–1: Lagrange coefficients (ECC chip only).
610 // Indices 2–4: Poseidon round constants A (rc_a).
611 // Indices 5–7: Poseidon round constants B (rc_b).
612 let lagrange_coeffs: [Column<Fixed>; 8] =
613 core::array::from_fn(|_| meta.fixed_column());
614 let rc_a = lagrange_coeffs[2..5].try_into().unwrap();
615 let rc_b = lagrange_coeffs[5..8].try_into().unwrap();
616
617 // Dedicated constants column, separate from the Lagrange coefficient
618 // columns used by the ECC chip. This prevents collisions between
619 // the ECC chip's fixed-base scalar multiplication tables and the
620 // constant-zero cells created by strict range checks.
621 let constants = meta.fixed_column();
622 meta.enable_constant(constants);
623
624 // AddChip: constrains `a + b = c` in a single row.
625 // Column assignment matches the delegation circuit:
626 // a = advices[7], b = advices[8], c = advices[6].
627 let add_config = AddChip::configure(meta, advices[7], advices[8], advices[6]);
628
629 // Lookup table columns for Sinsemilla (3 columns) and range checks.
630 // The first column (table_idx) is shared between Sinsemilla and
631 // LookupRangeCheckConfig. SinsemillaChip::load populates all three
632 // during synthesis (replacing the manual table loading).
633 let table_idx = meta.lookup_table_column();
634 let lookup = (
635 table_idx,
636 meta.lookup_table_column(),
637 meta.lookup_table_column(),
638 );
639
640 // Range check configuration: 10-bit lookup words in advices[9].
641 let range_check = LookupRangeCheckConfig::configure(meta, advices[9], table_idx);
642
643 // ECC chip: fixed- and variable-base scalar multiplication for
644 // condition 3 (diversified address integrity via CommitIvk chain) and condition 11
645 // (El Gamal encryption integrity).
646 // Shares columns with Poseidon per delegation circuit layout.
647 let ecc_config =
648 EccChip::<OrchardFixedBases>::configure(meta, advices, lagrange_coeffs, range_check);
649
650 // Sinsemilla chip: required by CommitIvk for condition 3.
651 // Uses advices[0..5] for Sinsemilla message hashing, advices[6] for
652 // witnessing message pieces, and lagrange_coeffs[0] for the fixed
653 // y_Q column. Shares the lookup table with LookupRangeCheckConfig.
654 let sinsemilla_config = SinsemillaChip::configure(
655 meta,
656 advices[..5].try_into().unwrap(),
657 advices[6],
658 lagrange_coeffs[0],
659 lookup,
660 range_check,
661 false,
662 );
663
664 // CommitIvk chip: canonicity checks on the ak || nk decomposition
665 // inside the CommitIvk Sinsemilla commitment (condition 3).
666 let commit_ivk_config = CommitIvkChip::configure(meta, advices);
667
668 // Poseidon chip: P128Pow5T3 with width 3, rate 2.
669 // State columns: advices[6..9] (3 columns for the width-3 state).
670 // Partial S-box column: advices[5].
671 // Round constants: lagrange_coeffs[2..5] (rc_a), [5..8] (rc_b).
672 let poseidon_config = PoseidonChip::configure::<poseidon::P128Pow5T3>(
673 meta,
674 advices[6..9].try_into().unwrap(),
675 advices[5],
676 rc_a,
677 rc_b,
678 );
679
680 // Merkle conditional swap gate (condition 1).
681 let merkle_swap = MerkleSwapGate::configure(
682 meta,
683 [advices[0], advices[1], advices[2], advices[3], advices[4]],
684 );
685
686 // Condition 6: Proposal Authority Decrement.
687 let authority_decrement = AuthorityDecrementChip::configure(meta, advices);
688
689 Config {
690 primary,
691 advices,
692 poseidon_config,
693 add_config,
694 ecc_config,
695 sinsemilla_config,
696 commit_ivk_config,
697 range_check,
698 merkle_swap,
699 authority_decrement,
700 }
701 }
702
703 #[allow(non_snake_case)]
704 fn synthesize(
705 &self,
706 config: Self::Config,
707 mut layouter: impl Layouter<pallas::Base>,
708 ) -> Result<(), plonk::Error> {
709 // ---------------------------------------------------------------
710 // Load the Sinsemilla generator lookup table.
711 //
712 // Populates the 10-bit lookup table and Sinsemilla generator
713 // points. Required by CommitIvk (condition 3), and also provides
714 // the range check table used by conditions 5 and 8.
715 // ---------------------------------------------------------------
716 SinsemillaChip::load(config.sinsemilla_config.clone(), &mut layouter)?;
717
718 // Load (proposal_id, 2^proposal_id) lookup table for condition 6.
719 AuthorityDecrementChip::load_table(&config.authority_decrement, &mut layouter)?;
720
721
722 // Construct the ECC chip (used in conditions 3 and 10).
723 let ecc_chip = config.ecc_chip();
724
725 // ---------------------------------------------------------------
726 // Witness assignment for condition 2.
727 // ---------------------------------------------------------------
728
729 // Copy voting_round_id from the instance column into an advice cell.
730 // This creates an equality constraint between the advice cell and the
731 // instance at offset VOTING_ROUND_ID, ensuring the in-circuit value
732 // matches the public input.
733 let voting_round_id = layouter.assign_region(
734 || "copy voting_round_id from instance",
735 |mut region| {
736 region.assign_advice_from_instance(
737 || "voting_round_id",
738 config.primary,
739 VOTING_ROUND_ID,
740 config.advices[0],
741 0,
742 )
743 },
744 )?;
745 // Clone for condition 12 (vote commitment integrity) before
746 // condition 2 consumes the original via van_integrity_poseidon.
747 let voting_round_id_cond12 = voting_round_id.clone();
748
749 // Witness vpk_g_d as a full non-identity curve point (condition 3 needs
750 // the point for variable-base ECC mul; conditions 2/6 need the x-coordinate
751 // for Poseidon hashing).
752 let vpk_g_d_point = NonIdentityPoint::new(
753 ecc_chip.clone(),
754 layouter.namespace(|| "witness vpk_g_d"),
755 self.vpk_g_d.map(|p| p),
756 )?;
757 let vpk_g_d = vpk_g_d_point.extract_p().inner().clone();
758
759 // Witness vpk_pk_d as a full non-identity curve point (condition 3
760 // constrains the derived point to equal this; conditions 2/6 use x-coordinate).
761 let vpk_pk_d_point = NonIdentityPoint::new(
762 ecc_chip.clone(),
763 layouter.namespace(|| "witness vpk_pk_d"),
764 self.vpk_pk_d.map(|p| p),
765 )?;
766 let vpk_pk_d = vpk_pk_d_point.extract_p().inner().clone();
767
768 let total_note_value = assign_free_advice(
769 layouter.namespace(|| "witness total_note_value"),
770 config.advices[0],
771 self.total_note_value,
772 )?;
773
774 let proposal_authority_old = assign_free_advice(
775 layouter.namespace(|| "witness proposal_authority_old"),
776 config.advices[0],
777 self.proposal_authority_old,
778 )?;
779
780 let van_comm_rand = assign_free_advice(
781 layouter.namespace(|| "witness van_comm_rand"),
782 config.advices[0],
783 self.van_comm_rand,
784 )?;
785
786 let vote_authority_note_old = assign_free_advice(
787 layouter.namespace(|| "witness vote_authority_note_old"),
788 config.advices[0],
789 self.vote_authority_note_old,
790 )?;
791
792 // DOMAIN_VAN — constant-constrained so the value is baked into the
793 // verification key and cannot be altered by a malicious prover.
794 let domain_van = layouter.assign_region(
795 || "DOMAIN_VAN constant",
796 |mut region| {
797 region.assign_advice_from_constant(
798 || "domain_van",
799 config.advices[0],
800 0,
801 pallas::Base::from(DOMAIN_VAN),
802 )
803 },
804 )?;
805
806 // ---------------------------------------------------------------
807 // Witness assignment for conditions 3 and 4.
808 //
809 // vsk_nk is shared between condition 3 (CommitIvk input) and
810 // condition 5 (VAN nullifier). Witnessed here so it's available
811 // for condition 3 which runs before condition 5.
812 // ---------------------------------------------------------------
813
814 // Private witness: nullifier deriving key (shared by conditions 3, 4).
815 let vsk_nk = assign_free_advice(
816 layouter.namespace(|| "witness vsk_nk"),
817 config.advices[0],
818 self.vsk_nk,
819 )?;
820
821 // Clone cells that are consumed by condition 2's Poseidon hash but
822 // reused in later conditions:
823 // - vote_authority_note_old: also used in condition 1 (Merkle leaf).
824 // - voting_round_id: also used in condition 5 (VAN nullifier).
825 // - vpk_g_d, vpk_pk_d, total_note_value, voting_round_id,
826 // van_comm_rand, domain_van: also used in condition 7 (new VAN integrity).
827 // - total_note_value: also used in condition 8 (shares sum check).
828 // - vsk_nk: also used in condition 5 (VAN nullifier).
829 let vote_authority_note_old_cond1 = vote_authority_note_old.clone();
830 let voting_round_id_cond4 = voting_round_id.clone();
831 let domain_van_cond6 = domain_van.clone();
832 let vpk_g_d_cond6 = vpk_g_d.clone();
833 let vpk_pk_d_cond6 = vpk_pk_d.clone();
834 let total_note_value_cond6 = total_note_value.clone();
835 let total_note_value_cond8 = total_note_value.clone();
836 let voting_round_id_cond6 = voting_round_id.clone();
837 let van_comm_rand_cond6 = van_comm_rand.clone();
838 let vsk_nk_cond4 = vsk_nk.clone();
839
840 // ---------------------------------------------------------------
841 // Condition 2: VAN Integrity (ZKP 1–compatible two-layer hash).
842 // van_comm_core = Poseidon(DOMAIN_VAN, vpk_g_d, vpk_pk_d, total_note_value,
843 // voting_round_id, proposal_authority_old)
844 // vote_authority_note_old = Poseidon(van_comm_core, van_comm_rand)
845 // ---------------------------------------------------------------
846
847 let derived_van = van_integrity::van_integrity_poseidon(
848 &config.poseidon_config,
849 &mut layouter,
850 "Old VAN integrity",
851 domain_van,
852 vpk_g_d,
853 vpk_pk_d,
854 total_note_value,
855 voting_round_id,
856 proposal_authority_old.clone(),
857 van_comm_rand,
858 )?;
859
860 // Constrain: derived VAN hash == witnessed vote_authority_note_old.
861 layouter.assign_region(
862 || "VAN integrity check",
863 |mut region| region.constrain_equal(derived_van.cell(), vote_authority_note_old.cell()),
864 )?;
865
866 // ---------------------------------------------------------------
867 // Condition 3: Diversified Address Integrity.
868 //
869 // vpk_pk_d = [ivk_v] * vpk_g_d where ivk_v = CommitIvk(ExtractP([vsk]*SpendAuthG), vsk_nk, rivk_v).
870 // ---------------------------------------------------------------
871 let vsk_scalar = ScalarFixed::new(
872 ecc_chip.clone(),
873 layouter.namespace(|| "cond3 vsk"),
874 self.vsk,
875 )?;
876 let vsk_ak_point = spend_auth_g_mul(
877 ecc_chip.clone(),
878 layouter.namespace(|| "cond3 [vsk]G"),
879 "cond3: [vsk] SpendAuthG",
880 vsk_scalar,
881 )?;
882 let ak = vsk_ak_point.extract_p().inner().clone();
883 let rivk_v_scalar = ScalarFixed::new(
884 ecc_chip.clone(),
885 layouter.namespace(|| "cond3 rivk_v"),
886 self.rivk_v,
887 )?;
888 prove_address_ownership(
889 config.sinsemilla_chip(),
890 ecc_chip.clone(),
891 config.commit_ivk_chip(),
892 layouter.namespace(|| "cond3 address"),
893 "cond3",
894 ak,
895 vsk_nk.clone(),
896 rivk_v_scalar,
897 &vpk_g_d_point,
898 &vpk_pk_d_point,
899 )?;
900
901 // ---------------------------------------------------------------
902 // Condition 4: Spend authority.
903 // r_vpk = [alpha_v] * SpendAuthG + vsk_ak_point
904 // ---------------------------------------------------------------
905 // Spend authority: proves that the public r_vpk is a valid rerandomization of the prover's ak.
906 // The out-of-circuit verifier checks that the vote signature is valid under r_vpk,
907 // so this links the ZKP to the signature without revealing ak.
908 //
909 // Uses the shared gadget from crate::circuit::spend_authority – a 1:1 copy of
910 // the upstream Orchard spend authority check:
911 // https://github.com/zcash/orchard/blob/main/src/circuit.rs#L542-L558
912 crate::circuit::spend_authority::prove_spend_authority(
913 ecc_chip.clone(),
914 layouter.namespace(|| "cond4 spend authority"),
915 self.alpha_v,
916 &vsk_ak_point,
917 config.primary,
918 R_VPK_X,
919 R_VPK_Y,
920 )?;
921
922 // ---------------------------------------------------------------
923 // Condition 1: VAN Membership.
924 //
925 // MerklePath(vote_authority_note_old, position, path) = vote_comm_tree_root
926 //
927 // Poseidon-based Merkle path verification (24 levels). At each
928 // level, the position bit determines child ordering: if bit=0,
929 // current is the left child; if bit=1, current is the right child.
930 //
931 // The leaf is vote_authority_note_old, which is already constrained
932 // to be a correct Poseidon hash by condition 2. This creates a
933 // binding: the VAN integrity check and the Merkle membership proof
934 // are tied to the same commitment.
935 //
936 // The hash function is Poseidon(left, right) with no level tag,
937 // matching vote_commitment_tree::MerkleHashVote::combine.
938 // ---------------------------------------------------------------
939 {
940 let root = synthesize_poseidon_merkle_path::<VOTE_COMM_TREE_DEPTH>(
941 &config.merkle_swap,
942 &config.poseidon_config,
943 &mut layouter,
944 config.advices[0],
945 vote_authority_note_old_cond1,
946 self.vote_comm_tree_position,
947 self.vote_comm_tree_path,
948 "cond1: merkle",
949 )?;
950
951 // Bind the computed Merkle root to the VOTE_COMM_TREE_ROOT
952 // public input. The verifier checks that the voter's VAN is
953 // a leaf in the published vote commitment tree.
954 layouter.constrain_instance(
955 root.cell(),
956 config.primary,
957 VOTE_COMM_TREE_ROOT,
958 )?;
959 }
960
961 // ---------------------------------------------------------------
962 // Witness assignment for condition 5.
963 //
964 // vsk_nk was already witnessed before condition 3 (shared between
965 // conditions 3 and 5). The vsk_nk_cond4 clone is used here.
966 // ---------------------------------------------------------------
967
968 // "vote authority spend" domain tag — constant-constrained so the
969 // value is baked into the verification key.
970 let domain_van_nf = layouter.assign_region(
971 || "DOMAIN_VAN_NULLIFIER constant",
972 |mut region| {
973 region.assign_advice_from_constant(
974 || "domain_van_nullifier",
975 config.advices[0],
976 0,
977 domain_van_nullifier(),
978 )
979 },
980 )?;
981
982 // ---------------------------------------------------------------
983 // Condition 5: VAN Nullifier Integrity.
984 // van_nullifier = Poseidon(vsk_nk, domain_tag, voting_round_id, vote_authority_note_old)
985 //
986 // Single ConstantLength<4> Poseidon hash (2 permutations at rate=2).
987 //
988 // voting_round_id and vote_authority_note_old are reused from
989 // condition 2 via cell equality — these cells flow directly into
990 // the Poseidon state without being re-witnessed.
991 // ---------------------------------------------------------------
992
993 let van_nullifier = {
994 let hasher = PoseidonHash::<
995 pallas::Base,
996 _,
997 poseidon::P128Pow5T3,
998 ConstantLength<4>,
999 3, // WIDTH
1000 2, // RATE
1001 >::init(
1002 config.poseidon_chip(),
1003 layouter.namespace(|| "VAN nullifier Poseidon init"),
1004 )?;
1005 hasher.hash(
1006 layouter.namespace(|| "Poseidon(vsk_nk, domain, round_id, van_old)"),
1007 [vsk_nk_cond4, domain_van_nf, voting_round_id_cond4, vote_authority_note_old],
1008 )?
1009 };
1010
1011 // Bind the derived nullifier to the VAN_NULLIFIER public input.
1012 // The verifier checks that the prover's computed nullifier matches
1013 // the publicly posted value, preventing double-voting.
1014 layouter.constrain_instance(van_nullifier.cell(), config.primary, VAN_NULLIFIER)?;
1015
1016 // ---------------------------------------------------------------
1017 // Condition 6: Proposal Authority Decrement (bit decomposition).
1018 //
1019 // Step 1: Decompose proposal_authority_old into 16 bits b_i (boolean).
1020 // Step 2: Selector sel_i = 1 iff proposal_id == i; exactly one active;
1021 // selected bit = sum(sel_i * b_i) = 1 (voter has authority).
1022 // Step 3: b_new_i = b_i*(1-sel_i); recompose to proposal_authority_new.
1023 // No diff/gap range check; decomposition proves [0, 2^16).
1024 // ---------------------------------------------------------------
1025
1026 // Copy proposal_id from the public instance into an advice cell.
1027 let proposal_id = layouter.assign_region(
1028 || "copy proposal_id from instance",
1029 |mut region| {
1030 region.assign_advice_from_instance(
1031 || "proposal_id",
1032 config.primary,
1033 PROPOSAL_ID,
1034 config.advices[0],
1035 0,
1036 )
1037 },
1038 )?;
1039
1040 let proposal_authority_new = AuthorityDecrementChip::assign(
1041 &config.authority_decrement,
1042 &mut layouter,
1043 proposal_id.clone(),
1044 proposal_authority_old,
1045 self.one_shifted,
1046 )?;
1047
1048 // ---------------------------------------------------------------
1049 // Condition 7: New VAN Integrity (ZKP 1–compatible two-layer hash).
1050 //
1051 // Same structure as condition 2; proposal_authority_new (from
1052 // condition 6) replaces proposal_authority_old. vpk_g_d and vpk_pk_d
1053 // are unchanged (same diversified address).
1054 // ---------------------------------------------------------------
1055
1056 let derived_van_new = van_integrity::van_integrity_poseidon(
1057 &config.poseidon_config,
1058 &mut layouter,
1059 "New VAN integrity",
1060 domain_van_cond6,
1061 vpk_g_d_cond6,
1062 vpk_pk_d_cond6,
1063 total_note_value_cond6,
1064 voting_round_id_cond6,
1065 proposal_authority_new,
1066 van_comm_rand_cond6,
1067 )?;
1068
1069 // Bind the derived new VAN to the VOTE_AUTHORITY_NOTE_NEW public input.
1070 // The verifier checks that the new VAN commitment posted on-chain is
1071 // correctly formed with decremented proposal authority.
1072 layouter.constrain_instance(
1073 derived_van_new.cell(),
1074 config.primary,
1075 VOTE_AUTHORITY_NOTE_NEW,
1076 )?;
1077
1078 // ---------------------------------------------------------------
1079 // Condition 8: Shares Sum Correctness.
1080 //
1081 // sum(share_0, ..., share_15) = total_note_value
1082 //
1083 // Proves the voting share decomposition is consistent with the
1084 // total delegated weight (in ballots). Uses 15 chained AddChip additions:
1085 // partial_1 = share_0 + share_1
1086 // partial_2 = partial_1 + share_2
1087 // ...
1088 // shares_sum = partial_14 + share_15
1089 // Then constrains shares_sum == total_note_value (from condition 2).
1090 // ---------------------------------------------------------------
1091
1092 // Witness the 16 plaintext shares. These cells are also used
1093 // by condition 9 (range check) and condition 11 (El Gamal
1094 // encryption inputs).
1095 let share_cells: [_; 16] = (0..16usize)
1096 .map(|i| assign_free_advice(
1097 layouter.namespace(|| alloc::format!("witness share_{i}")),
1098 config.advices[0],
1099 self.shares[i],
1100 ))
1101 .collect::<Result<Vec<_>, _>>()?
1102 .try_into()
1103 .expect("always 16 elements");
1104
1105 // Chain 15 additions: share_0 + share_1 + ... + share_15.
1106 let shares_sum = share_cells[1..].iter().enumerate().try_fold(
1107 share_cells[0].clone(),
1108 |acc, (i, share)| {
1109 config.add_chip().add(
1110 layouter.namespace(|| alloc::format!("shares sum step {}", i + 1)),
1111 &acc,
1112 share,
1113 )
1114 },
1115 )?;
1116
1117 // Constrain: shares_sum == total_note_value.
1118 // This ensures the 16 shares decompose the voter's total delegated
1119 // weight without creating or destroying value.
1120 layouter.assign_region(
1121 || "shares sum == total_note_value",
1122 |mut region| {
1123 region.constrain_equal(shares_sum.cell(), total_note_value_cond8.cell())
1124 },
1125 )?;
1126
1127 // ---------------------------------------------------------------
1128 // Condition 9: Shares Range.
1129 //
1130 // Each share_i in [0, 2^30)
1131 //
1132 // Motivation: the sum constraint (condition 8) holds in the
1133 // base field F_p, but El Gamal encryption operates in the
1134 // scalar field F_q via `share_i * G`. For Pallas, p ≠ q, so a
1135 // large base-field element (e.g. p − 50) reduces to a different
1136 // value mod q, breaking the correspondence between the
1137 // constrained sum and the encrypted values. Bounding each share
1138 // to [0, 2^30) guarantees both representations agree (no
1139 // modular reduction in either field), so the homomorphic tally
1140 // faithfully reflects condition 8's sum.
1141 //
1142 // Secondary benefit: after accumulation the EA decrypts to
1143 // `total_value * G` and must solve a bounded DLOG (BSGS) to
1144 // recover `total_value`. Bounded shares keep the per-decision
1145 // aggregate small enough for efficient recovery.
1146 //
1147 // Shares are denominated in ballots (1 ballot = 0.125 ZEC),
1148 // converted from zatoshi in ZKP #1's condition 8 (ballot
1149 // scaling). Uses 3 × 10-bit lookup words with strict mode,
1150 // giving [0, 2^30). halo2_gadgets v0.3's `short_range_check`
1151 // is private, so exact non-10-bit-aligned bounds (e.g. 24-bit)
1152 // are unavailable. 2^30 ballots ≈ 134M ZEC — well above the
1153 // 21M ZEC supply — so the bound is never binding in practice.
1154 //
1155 // If a share exceeds 2^30 (or wraps around the field, e.g.
1156 // from underflow), the 3-word decomposition produces a non-zero
1157 // z_3 running sum, which fails the strict check.
1158 // ---------------------------------------------------------------
1159
1160 // Share cells are cloned because copy_check takes ownership;
1161 // the originals remain available for condition 11 (El Gamal).
1162 for (i, cell) in share_cells.iter().enumerate() {
1163 config.range_check_config().copy_check(
1164 layouter.namespace(|| alloc::format!("share_{i} < 2^30")),
1165 cell.clone(),
1166 3, // num_words: 3 × 10 = 30 bits
1167 true, // strict: running sum terminates at 0
1168 )?;
1169 }
1170
1171 // ---------------------------------------------------------------
1172 // Condition 10: Shares Hash Integrity (blinded commitments).
1173 //
1174 // share_comm_i = Poseidon(blind_i, c1_i_x, c2_i_x, c1_i_y, c2_i_y)
1175 // shares_hash = Poseidon(share_comm_0, ..., share_comm_15)
1176 //
1177 // The y-coordinates bind each share commitment to the exact curve
1178 // point, preventing ciphertext sign-malleability attacks.
1179 // The blind factors prevent on-chain observers from recomputing
1180 // shares_hash. shares_hash is an internal wire; it is not bound to
1181 // the instance column. Condition 11 constrains that each
1182 // (c1_i_x, c2_i_x, c1_i_y, c2_i_y) is a valid El Gamal encryption
1183 // of shares_i. Condition 12 computes the full vote commitment
1184 // H(DOMAIN_VC, voting_round_id, shares_hash, proposal_id, vote_decision)
1185 // and binds that value to the VOTE_COMMITMENT public input.
1186 // ---------------------------------------------------------------
1187
1188 let blinds: [AssignedCell<pallas::Base, pallas::Base>; 16] = (0..16)
1189 .map(|i| {
1190 assign_free_advice(
1191 layouter.namespace(|| alloc::format!("witness share_blind[{i}]")),
1192 config.advices[0],
1193 self.share_blinds[i],
1194 )
1195 })
1196 .collect::<Result<Vec<_>, _>>()?
1197 .try_into()
1198 .expect("always 16 elements");
1199
1200 let enc_c1: [AssignedCell<pallas::Base, pallas::Base>; 16] = (0..16)
1201 .map(|i| assign_free_advice(
1202 layouter.namespace(|| alloc::format!("witness enc_c1_x[{i}]")),
1203 config.advices[0],
1204 self.enc_share_c1_x[i],
1205 ))
1206 .collect::<Result<Vec<_>, _>>()?
1207 .try_into()
1208 .expect("always 16 elements");
1209
1210 let enc_c2: [AssignedCell<pallas::Base, pallas::Base>; 16] = (0..16)
1211 .map(|i| assign_free_advice(
1212 layouter.namespace(|| alloc::format!("witness enc_c2_x[{i}]")),
1213 config.advices[0],
1214 self.enc_share_c2_x[i],
1215 ))
1216 .collect::<Result<Vec<_>, _>>()?
1217 .try_into()
1218 .expect("always 16 elements");
1219
1220 let enc_c1_y: [AssignedCell<pallas::Base, pallas::Base>; 16] = (0..16)
1221 .map(|i| assign_free_advice(
1222 layouter.namespace(|| alloc::format!("witness enc_c1_y[{i}]")),
1223 config.advices[0],
1224 self.enc_share_c1_y[i],
1225 ))
1226 .collect::<Result<Vec<_>, _>>()?
1227 .try_into()
1228 .expect("always 16 elements");
1229
1230 let enc_c2_y: [AssignedCell<pallas::Base, pallas::Base>; 16] = (0..16)
1231 .map(|i| assign_free_advice(
1232 layouter.namespace(|| alloc::format!("witness enc_c2_y[{i}]")),
1233 config.advices[0],
1234 self.enc_share_c2_y[i],
1235 ))
1236 .collect::<Result<Vec<_>, _>>()?
1237 .try_into()
1238 .expect("always 16 elements");
1239
1240 // Clone for Condition 11 before compute_shares_hash_in_circuit takes ownership.
1241 let enc_c1_cond11: [AssignedCell<pallas::Base, pallas::Base>; 16] =
1242 core::array::from_fn(|i| enc_c1[i].clone());
1243 let enc_c2_cond11: [AssignedCell<pallas::Base, pallas::Base>; 16] =
1244 core::array::from_fn(|i| enc_c2[i].clone());
1245 let enc_c1_y_cond11: [AssignedCell<pallas::Base, pallas::Base>; 16] =
1246 core::array::from_fn(|i| enc_c1_y[i].clone());
1247 let enc_c2_y_cond11: [AssignedCell<pallas::Base, pallas::Base>; 16] =
1248 core::array::from_fn(|i| enc_c2_y[i].clone());
1249
1250 let shares_hash = compute_shares_hash_in_circuit(
1251 || config.poseidon_chip(),
1252 layouter.namespace(|| "cond10: shares hash"),
1253 blinds,
1254 enc_c1,
1255 enc_c2,
1256 enc_c1_y,
1257 enc_c2_y,
1258 )?;
1259
1260 // ---------------------------------------------------------------
1261 // Condition 11: Encryption Integrity.
1262 //
1263 // For each share i: C1_i = [r_i]*G, C2_i = [v_i]*G + [r_i]*ea_pk;
1264 // Both coordinates of C1_i and C2_i are constrained to the
1265 // witnessed enc_share cells. Implemented by the shared
1266 // circuit::elgamal::prove_elgamal_encryptions gadget.
1267 // ---------------------------------------------------------------
1268 {
1269 let r_cells: [_; 16] = (0..16usize)
1270 .map(|i| assign_free_advice(
1271 layouter.namespace(|| alloc::format!("witness r[{i}]")),
1272 config.advices[0],
1273 self.share_randomness[i],
1274 ))
1275 .collect::<Result<Vec<_>, _>>()?
1276 .try_into()
1277 .expect("always 16 elements");
1278
1279 prove_elgamal_encryptions(
1280 ecc_chip.clone(),
1281 layouter.namespace(|| "cond11 El Gamal"),
1282 "cond11",
1283 self.ea_pk,
1284 EaPkInstanceLoc {
1285 instance: config.primary,
1286 x_row: EA_PK_X,
1287 y_row: EA_PK_Y,
1288 },
1289 config.advices[0],
1290 share_cells,
1291 r_cells,
1292 enc_c1_cond11,
1293 enc_c2_cond11,
1294 enc_c1_y_cond11,
1295 enc_c2_y_cond11,
1296 )?;
1297 }
1298
1299 // ---------------------------------------------------------------
1300 // Condition 12: Vote Commitment Integrity.
1301 //
1302 // vote_commitment = Poseidon(DOMAIN_VC, voting_round_id,
1303 // shares_hash, proposal_id, vote_decision)
1304 //
1305 // Binds the voting round, encrypted shares (via shares_hash from
1306 // condition 10), the proposal choice, and the vote decision into a
1307 // single commitment with domain separation from VANs (DOMAIN_VC = 1).
1308 //
1309 // This is the value posted on-chain and later inserted into the
1310 // vote commitment tree. ZKP #3 (vote reveal) will open individual
1311 // shares from this commitment.
1312 // ---------------------------------------------------------------
1313
1314 // DOMAIN_VC — constant-constrained so the value is baked into the
1315 // verification key and cannot be altered by a malicious prover.
1316 let domain_vc = layouter.assign_region(
1317 || "DOMAIN_VC constant",
1318 |mut region| {
1319 region.assign_advice_from_constant(
1320 || "domain_vc",
1321 config.advices[0],
1322 0,
1323 pallas::Base::from(DOMAIN_VC),
1324 )
1325 },
1326 )?;
1327
1328 // proposal_id was already copied from instance in condition 6; reuse that cell.
1329
1330 // Private witness: vote decision.
1331 let vote_decision = assign_free_advice(
1332 layouter.namespace(|| "witness vote_decision"),
1333 config.advices[0],
1334 self.vote_decision,
1335 )?;
1336
1337 // Compute vote_commitment = Poseidon(DOMAIN_VC, voting_round_id,
1338 // shares_hash, proposal_id, vote_decision).
1339 let vote_commitment = vote_commitment::vote_commitment_poseidon(
1340 &config.poseidon_config,
1341 &mut layouter,
1342 "cond12",
1343 domain_vc,
1344 voting_round_id_cond12,
1345 shares_hash,
1346 proposal_id,
1347 vote_decision,
1348 )?;
1349
1350 // Bind the derived vote commitment to the VOTE_COMMITMENT public input.
1351 layouter.constrain_instance(
1352 vote_commitment.cell(),
1353 config.primary,
1354 VOTE_COMMITMENT,
1355 )?;
1356
1357 Ok(())
1358 }
1359}
1360
1361// ================================================================
1362// Instance (public inputs)
1363// ================================================================
1364
1365/// Public inputs to the Vote Proof circuit (11 field elements).
1366///
1367/// These are the values posted to the vote chain that both the prover
1368/// and verifier agree on. The verifier checks the proof against these
1369/// values without seeing any private witnesses.
1370#[derive(Clone, Debug)]
1371pub struct Instance {
1372 /// The nullifier of the old VAN being spent (prevents double-vote).
1373 pub van_nullifier: pallas::Base,
1374 /// Randomized voting public key (condition 4): x-coordinate of r_vpk = vsk.ak + [alpha_v] * G.
1375 pub r_vpk_x: pallas::Base,
1376 /// Randomized voting public key: y-coordinate.
1377 pub r_vpk_y: pallas::Base,
1378 /// The new VAN commitment (with decremented proposal authority).
1379 pub vote_authority_note_new: pallas::Base,
1380 /// The vote commitment hash.
1381 pub vote_commitment: pallas::Base,
1382 /// Root of the vote commitment tree at anchor height.
1383 pub vote_comm_tree_root: pallas::Base,
1384 /// The vote-chain height at which the tree is snapshotted.
1385 pub vote_comm_tree_anchor_height: pallas::Base,
1386 /// Which proposal this vote is for.
1387 pub proposal_id: pallas::Base,
1388 /// The voting round identifier.
1389 pub voting_round_id: pallas::Base,
1390 /// Election authority public key x-coordinate.
1391 pub ea_pk_x: pallas::Base,
1392 /// Election authority public key y-coordinate.
1393 pub ea_pk_y: pallas::Base,
1394}
1395
1396impl Instance {
1397 /// Constructs an [`Instance`] from its constituent parts.
1398 pub fn from_parts(
1399 van_nullifier: pallas::Base,
1400 r_vpk_x: pallas::Base,
1401 r_vpk_y: pallas::Base,
1402 vote_authority_note_new: pallas::Base,
1403 vote_commitment: pallas::Base,
1404 vote_comm_tree_root: pallas::Base,
1405 vote_comm_tree_anchor_height: pallas::Base,
1406 proposal_id: pallas::Base,
1407 voting_round_id: pallas::Base,
1408 ea_pk_x: pallas::Base,
1409 ea_pk_y: pallas::Base,
1410 ) -> Self {
1411 Instance {
1412 van_nullifier,
1413 r_vpk_x,
1414 r_vpk_y,
1415 vote_authority_note_new,
1416 vote_commitment,
1417 vote_comm_tree_root,
1418 vote_comm_tree_anchor_height,
1419 proposal_id,
1420 voting_round_id,
1421 ea_pk_x,
1422 ea_pk_y,
1423 }
1424 }
1425
1426 /// Serializes public inputs for halo2 proof creation/verification.
1427 ///
1428 /// The order must match the instance column offsets defined at the
1429 /// top of this file (`VAN_NULLIFIER`, `R_VPK_X`, `R_VPK_Y`, etc.).
1430 pub fn to_halo2_instance(&self) -> Vec<vesta::Scalar> {
1431 alloc::vec![
1432 self.van_nullifier,
1433 self.r_vpk_x,
1434 self.r_vpk_y,
1435 self.vote_authority_note_new,
1436 self.vote_commitment,
1437 self.vote_comm_tree_root,
1438 self.vote_comm_tree_anchor_height,
1439 self.proposal_id,
1440 self.voting_round_id,
1441 self.ea_pk_x,
1442 self.ea_pk_y,
1443 ]
1444 }
1445}
1446
1447// ================================================================
1448// Tests
1449// ================================================================
1450
1451#[cfg(test)]
1452mod tests {
1453 use super::*;
1454 use crate::circuit::elgamal::{base_to_scalar, elgamal_encrypt, spend_auth_g_affine};
1455 use core::iter;
1456 use ff::Field;
1457 use group::ff::PrimeFieldBits;
1458 use group::{Curve, Group};
1459 use halo2_gadgets::sinsemilla::primitives::CommitDomain;
1460 use halo2_proofs::dev::MockProver;
1461 use pasta_curves::arithmetic::CurveAffine;
1462 use pasta_curves::pallas;
1463 use rand::rngs::OsRng;
1464
1465 use orchard::constants::{
1466 fixed_bases::COMMIT_IVK_PERSONALIZATION,
1467 L_ORCHARD_BASE,
1468 };
1469
1470 /// Generates an El Gamal keypair for testing.
1471 /// Returns `(ea_sk, ea_pk_point, ea_pk_affine)`.
1472 fn generate_ea_keypair() -> (pallas::Scalar, pallas::Point, pallas::Affine) {
1473 let ea_sk = pallas::Scalar::from(42u64);
1474 let g = pallas::Point::from(spend_auth_g_affine());
1475 let ea_pk = g * ea_sk;
1476 let ea_pk_affine = ea_pk.to_affine();
1477 (ea_sk, ea_pk, ea_pk_affine)
1478 }
1479
1480 /// Computes real El Gamal encryptions for 16 shares.
1481 ///
1482 /// Returns `(c1_x, c2_x, c1_y, c2_y, randomness, share_blinds, shares_hash_value)` where:
1483 /// - `c1_x[i]` and `c2_x[i]` are correct ciphertext x-coordinates
1484 /// - `c1_y[i]` and `c2_y[i]` are correct ciphertext y-coordinates
1485 /// - `randomness[i]` is the base field randomness used for each share
1486 /// - `share_blinds[i]` is the blind factor for each share commitment
1487 /// - `shares_hash_value` is the blinded Poseidon hash of all shares
1488 fn encrypt_shares(
1489 shares: [u64; 16],
1490 ea_pk: pallas::Point,
1491 ) -> (
1492 [pallas::Base; 16],
1493 [pallas::Base; 16],
1494 [pallas::Base; 16],
1495 [pallas::Base; 16],
1496 [pallas::Base; 16],
1497 [pallas::Base; 16],
1498 pallas::Base,
1499 ) {
1500 let mut c1_x = [pallas::Base::zero(); 16];
1501 let mut c2_x = [pallas::Base::zero(); 16];
1502 let mut c1_y = [pallas::Base::zero(); 16];
1503 let mut c2_y = [pallas::Base::zero(); 16];
1504 // Use small deterministic randomness (fits in both Base and Scalar).
1505 let randomness: [pallas::Base; 16] = core::array::from_fn(|i| {
1506 pallas::Base::from((i as u64 + 1) * 101)
1507 });
1508 // Deterministic blind factors for tests.
1509 let share_blinds: [pallas::Base; 16] = core::array::from_fn(|i| {
1510 pallas::Base::from(1001u64 + i as u64)
1511 });
1512 for i in 0..16 {
1513 let (cx1, cx2, cy1, cy2) = elgamal_encrypt(
1514 pallas::Base::from(shares[i]),
1515 randomness[i],
1516 ea_pk,
1517 );
1518 c1_x[i] = cx1;
1519 c2_x[i] = cx2;
1520 c1_y[i] = cy1;
1521 c2_y[i] = cy2;
1522 }
1523 let hash = shares_hash(share_blinds, c1_x, c2_x, c1_y, c2_y);
1524 (c1_x, c2_x, c1_y, c2_y, randomness, share_blinds, hash)
1525 }
1526
1527 /// Out-of-circuit voting key derivation for tests.
1528 ///
1529 /// Given a voting spending key (vsk), nullifier key (nk), and CommitIvk
1530 /// randomness (rivk_v), derives the full voting address:
1531 ///
1532 /// 1. `ak = [vsk] * SpendAuthG` (spend validating key)
1533 /// 2. `ak_x = ExtractP(ak)` (x-coordinate)
1534 /// 3. `ivk_v = CommitIvk(ak_x, nk, rivk_v)` (incoming viewing key)
1535 /// 4. `g_d = random non-identity point` (diversified base)
1536 /// 5. `pk_d = [ivk_v] * g_d` (diversified transmission key)
1537 ///
1538 /// Returns `(g_d_affine, pk_d_affine, ak_x)` for use as circuit witnesses.
1539 fn derive_voting_address(
1540 vsk: pallas::Scalar,
1541 nk: pallas::Base,
1542 rivk_v: pallas::Scalar,
1543 ) -> (pallas::Affine, pallas::Affine) {
1544 // Step 1: ak = [vsk] * SpendAuthG
1545 let g = pallas::Point::from(spend_auth_g_affine());
1546 let ak_point = g * vsk;
1547 let ak_x = *ak_point.to_affine().coordinates().unwrap().x();
1548
1549 // Step 2: ivk_v = CommitIvk(ak_x, nk, rivk_v)
1550 let domain = CommitDomain::new(COMMIT_IVK_PERSONALIZATION);
1551 let ivk_v = domain
1552 .short_commit(
1553 iter::empty()
1554 .chain(ak_x.to_le_bits().iter().by_vals().take(L_ORCHARD_BASE))
1555 .chain(nk.to_le_bits().iter().by_vals().take(L_ORCHARD_BASE)),
1556 &rivk_v,
1557 )
1558 .expect("CommitIvk should not produce ⊥ for random inputs");
1559
1560 // Step 3: g_d = random non-identity point
1561 // Using a deterministic point derived from a fixed seed ensures
1562 // reproducibility while avoiding the identity point.
1563 let g_d = pallas::Point::generator() * pallas::Scalar::from(12345u64);
1564 let g_d_affine = g_d.to_affine();
1565
1566 // Step 4: pk_d = [ivk_v] * g_d
1567 let ivk_v_scalar =
1568 base_to_scalar(ivk_v).expect("ivk_v must be < scalar field modulus");
1569 let pk_d = g_d * ivk_v_scalar;
1570 let pk_d_affine = pk_d.to_affine();
1571
1572 (g_d_affine, pk_d_affine)
1573 }
1574
1575 /// Default proposal_id and vote_decision for tests.
1576 const TEST_PROPOSAL_ID: u64 = 3;
1577 const TEST_VOTE_DECISION: u64 = 1;
1578
1579 /// Sets condition 12 fields on a circuit and returns the vote_commitment.
1580 ///
1581 /// Computes `H(DOMAIN_VC, voting_round_id, shares_hash, proposal_id, vote_decision)`
1582 /// and sets `circuit.vote_decision`. Returns the vote_commitment
1583 /// for use in the Instance. The `proposal_id` must match the
1584 /// instance's proposal_id so the circuit's condition 12 (which
1585 /// copies proposal_id from the instance) agrees with the instance.
1586 fn set_condition_11(
1587 circuit: &mut Circuit,
1588 shares_hash_val: pallas::Base,
1589 proposal_id: u64,
1590 voting_round_id: pallas::Base,
1591 ) -> pallas::Base {
1592 let proposal_id_base = pallas::Base::from(proposal_id);
1593 let vote_decision = pallas::Base::from(TEST_VOTE_DECISION);
1594 circuit.vote_decision = Value::known(vote_decision);
1595 vote_commitment_hash(voting_round_id, shares_hash_val, proposal_id_base, vote_decision)
1596 }
1597
1598 /// Build valid test data for all 11 conditions.
1599 ///
1600 /// Returns a circuit with correctly-hashed VAN witnesses, valid
1601 /// shares, real El Gamal ciphertexts, and a matching instance.
1602 fn build_single_leaf_merkle_path(
1603 leaf: pallas::Base,
1604 ) -> ([pallas::Base; VOTE_COMM_TREE_DEPTH], u32, pallas::Base) {
1605 let mut empty_roots = [pallas::Base::zero(); VOTE_COMM_TREE_DEPTH];
1606 empty_roots[0] = poseidon_hash_2(pallas::Base::zero(), pallas::Base::zero());
1607 for i in 1..VOTE_COMM_TREE_DEPTH {
1608 empty_roots[i] = poseidon_hash_2(empty_roots[i - 1], empty_roots[i - 1]);
1609 }
1610 let auth_path = empty_roots;
1611 let mut current = leaf;
1612 for i in 0..VOTE_COMM_TREE_DEPTH {
1613 current = poseidon_hash_2(current, auth_path[i]);
1614 }
1615 (auth_path, 0, current)
1616 }
1617
1618 /// Build test (circuit, instance) with given proposal_authority_old and proposal_id.
1619 /// proposal_authority_old must have the proposal_id-th bit set (spec bitmask).
1620 fn make_test_data_with_authority_and_proposal(
1621 proposal_authority_old: pallas::Base,
1622 proposal_id: u64,
1623 ) -> (Circuit, Instance) {
1624 let mut rng = OsRng;
1625
1626 // Condition 3 (spend authority): derive proper voting key hierarchy.
1627 // vsk → ak → ivk_v → (vpk_g_d, vpk_pk_d) through CommitIvk chain.
1628 let vsk = pallas::Scalar::random(&mut rng);
1629 let vsk_nk = pallas::Base::random(&mut rng);
1630 let rivk_v = pallas::Scalar::random(&mut rng);
1631 let alpha_v = pallas::Scalar::random(&mut rng);
1632
1633 let (vpk_g_d_affine, vpk_pk_d_affine) = derive_voting_address(vsk, vsk_nk, rivk_v);
1634
1635 // Condition 4: r_vpk = ak + [alpha_v] * G
1636 let g = pallas::Point::from(spend_auth_g_affine());
1637 let ak_point = g * vsk;
1638 let r_vpk = (ak_point + g * alpha_v).to_affine();
1639 let r_vpk_x = *r_vpk.coordinates().unwrap().x();
1640 let r_vpk_y = *r_vpk.coordinates().unwrap().y();
1641
1642 // Extract x-coordinates for Poseidon hashing (conditions 2, 6).
1643 let vpk_g_d_x = *vpk_g_d_affine.coordinates().unwrap().x();
1644 let vpk_pk_d_x = *vpk_pk_d_affine.coordinates().unwrap().x();
1645
1646 // total_note_value must be small enough that all 16 shares
1647 // fit in [0, 2^30) for condition 9's range check.
1648 let total_note_value = pallas::Base::from(10_000u64);
1649 let voting_round_id = pallas::Base::random(&mut rng);
1650 let van_comm_rand = pallas::Base::random(&mut rng);
1651
1652 let vote_authority_note_old = van_integrity_hash(
1653 vpk_g_d_x,
1654 vpk_pk_d_x,
1655 total_note_value,
1656 voting_round_id,
1657 proposal_authority_old,
1658 van_comm_rand,
1659 );
1660 let (auth_path, position, vote_comm_tree_root) =
1661 build_single_leaf_merkle_path(vote_authority_note_old);
1662 let van_nullifier = van_nullifier_hash(vsk_nk, voting_round_id, vote_authority_note_old);
1663 // Spec: proposal_authority_new = proposal_authority_old - (1 << proposal_id).
1664 let one_shifted = pallas::Base::from(1u64 << proposal_id);
1665 let proposal_authority_new = proposal_authority_old - one_shifted;
1666 let vote_authority_note_new = van_integrity_hash(
1667 vpk_g_d_x,
1668 vpk_pk_d_x,
1669 total_note_value,
1670 voting_round_id,
1671 proposal_authority_new,
1672 van_comm_rand,
1673 );
1674
1675 // Create shares that sum to total_note_value (conditions 8 + 9).
1676 // Each share must be in [0, 2^30) for condition 9's range check.
1677 let shares_u64: [u64; 16] = [625; 16]; // sum = 10000
1678
1679 // Condition 11: El Gamal encryption of shares under ea_pk.
1680 let (_ea_sk, ea_pk_point, ea_pk_affine) = generate_ea_keypair();
1681 let ea_pk_x = *ea_pk_affine.coordinates().unwrap().x();
1682 let ea_pk_y = *ea_pk_affine.coordinates().unwrap().y();
1683 let (enc_c1_x, enc_c2_x, enc_c1_y, enc_c2_y, randomness, share_blinds, shares_hash_val) =
1684 encrypt_shares(shares_u64, ea_pk_point);
1685
1686 let mut circuit = Circuit::with_van_witnesses(
1687 Value::known(auth_path),
1688 Value::known(position),
1689 Value::known(vpk_g_d_affine),
1690 Value::known(vpk_pk_d_affine),
1691 Value::known(total_note_value),
1692 Value::known(proposal_authority_old),
1693 Value::known(van_comm_rand),
1694 Value::known(vote_authority_note_old),
1695 Value::known(vsk),
1696 Value::known(rivk_v),
1697 Value::known(vsk_nk),
1698 Value::known(alpha_v),
1699 );
1700 circuit.one_shifted = Value::known(one_shifted);
1701 circuit.shares = shares_u64.map(|s| Value::known(pallas::Base::from(s)));
1702 circuit.enc_share_c1_x = enc_c1_x.map(Value::known);
1703 circuit.enc_share_c2_x = enc_c2_x.map(Value::known);
1704 circuit.enc_share_c1_y = enc_c1_y.map(Value::known);
1705 circuit.enc_share_c2_y = enc_c2_y.map(Value::known);
1706 circuit.share_blinds = share_blinds.map(Value::known);
1707 circuit.share_randomness = randomness.map(Value::known);
1708 circuit.ea_pk = Value::known(ea_pk_affine);
1709
1710 // Condition 12: vote commitment from shares_hash + proposal + decision.
1711 let vote_commitment = set_condition_11(&mut circuit, shares_hash_val, proposal_id, voting_round_id);
1712
1713 let instance = Instance::from_parts(
1714 van_nullifier,
1715 r_vpk_x,
1716 r_vpk_y,
1717 vote_authority_note_new,
1718 vote_commitment,
1719 vote_comm_tree_root,
1720 pallas::Base::zero(),
1721 pallas::Base::from(proposal_id),
1722 voting_round_id,
1723 ea_pk_x,
1724 ea_pk_y,
1725 );
1726
1727 (circuit, instance)
1728 }
1729
1730 fn make_test_data_with_authority(proposal_authority_old: pallas::Base) -> (Circuit, Instance) {
1731 make_test_data_with_authority_and_proposal(proposal_authority_old, TEST_PROPOSAL_ID)
1732 }
1733
1734 fn make_test_data() -> (Circuit, Instance) {
1735 // proposal_authority_old must have bit TEST_PROPOSAL_ID set (spec bitmask).
1736 // 5 | (1 << 3) = 13 so we can vote on proposal 3 and get new = 5.
1737 make_test_data_with_authority(pallas::Base::from(13u64))
1738 }
1739
1740 // ================================================================
1741 // Condition 2 (VAN Integrity) tests
1742 // ================================================================
1743
1744 #[test]
1745 fn van_integrity_valid_proof() {
1746 let (circuit, instance) = make_test_data();
1747
1748 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1749
1750 assert_eq!(prover.verify(), Ok(()));
1751 }
1752
1753 #[test]
1754 fn van_integrity_wrong_hash_fails() {
1755 let mut rng = OsRng;
1756 let (_, mut instance) = make_test_data();
1757
1758 // Deliberately wrong VAN value — condition 2 constrain_equal will fail.
1759 let wrong_van = pallas::Base::random(&mut rng);
1760 let (auth_path, position, root) = build_single_leaf_merkle_path(wrong_van);
1761 instance.vote_comm_tree_root = root;
1762
1763 // Use properly derived keys (condition 3 would pass) but the VAN
1764 // hash won't match wrong_van, so condition 2 fails.
1765 let vsk = pallas::Scalar::random(&mut rng);
1766 let vsk_nk = pallas::Base::random(&mut rng);
1767 let rivk_v = pallas::Scalar::random(&mut rng);
1768 let alpha_v = pallas::Scalar::random(&mut rng);
1769 let (vpk_g_d_affine, vpk_pk_d_affine) = derive_voting_address(vsk, vsk_nk, rivk_v);
1770 let g = pallas::Point::from(spend_auth_g_affine());
1771 let r_vpk = (g * vsk + g * alpha_v).to_affine();
1772 instance.r_vpk_x = *r_vpk.coordinates().unwrap().x();
1773 instance.r_vpk_y = *r_vpk.coordinates().unwrap().y();
1774
1775 let shares_u64: [u64; 16] = [625; 16];
1776 let (_ea_sk, ea_pk_point, ea_pk_affine) = generate_ea_keypair();
1777 let (enc_c1_x, enc_c2_x, enc_c1_y, enc_c2_y, randomness, share_blinds, shares_hash_val) =
1778 encrypt_shares(shares_u64, ea_pk_point);
1779
1780 // Use authority 13 (bit 3 set) and one_shifted = 8 so condition 6 is consistent;
1781 // only condition 2 (VAN hash) should fail due to wrong_van.
1782 let proposal_authority_old = pallas::Base::from(13u64);
1783 let van_comm_rand = pallas::Base::random(&mut rng);
1784 let mut circuit = Circuit::with_van_witnesses(
1785 Value::known(auth_path),
1786 Value::known(position),
1787 Value::known(vpk_g_d_affine),
1788 Value::known(vpk_pk_d_affine),
1789 Value::known(pallas::Base::from(10_000u64)),
1790 Value::known(proposal_authority_old),
1791 Value::known(van_comm_rand),
1792 Value::known(wrong_van),
1793 Value::known(vsk),
1794 Value::known(rivk_v),
1795 Value::known(vsk_nk),
1796 Value::known(alpha_v),
1797 );
1798 circuit.one_shifted = Value::known(pallas::Base::from(1u64 << TEST_PROPOSAL_ID));
1799 circuit.shares = shares_u64.map(|s| Value::known(pallas::Base::from(s)));
1800 circuit.enc_share_c1_x = enc_c1_x.map(Value::known);
1801 circuit.enc_share_c2_x = enc_c2_x.map(Value::known);
1802 circuit.enc_share_c1_y = enc_c1_y.map(Value::known);
1803 circuit.enc_share_c2_y = enc_c2_y.map(Value::known);
1804 circuit.share_blinds = share_blinds.map(Value::known);
1805 circuit.share_randomness = randomness.map(Value::known);
1806 circuit.ea_pk = Value::known(ea_pk_affine);
1807 let vc = set_condition_11(&mut circuit, shares_hash_val, TEST_PROPOSAL_ID, instance.voting_round_id);
1808 instance.vote_commitment = vc;
1809 instance.proposal_id = pallas::Base::from(TEST_PROPOSAL_ID);
1810 instance.ea_pk_x = *ea_pk_affine.coordinates().unwrap().x();
1811 instance.ea_pk_y = *ea_pk_affine.coordinates().unwrap().y();
1812
1813 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1814 // Should fail: derived hash ≠ witnessed vote_authority_note_old.
1815 assert!(prover.verify().is_err());
1816 }
1817
1818 #[test]
1819 fn van_integrity_wrong_round_id_fails() {
1820 let (circuit, mut instance) = make_test_data();
1821
1822 // Supply a DIFFERENT voting_round_id in the instance.
1823 instance.voting_round_id = pallas::Base::random(&mut OsRng);
1824
1825 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1826 // Should fail: the voting_round_id from the instance doesn't match
1827 // the one hashed into the VAN (condition 2).
1828 assert!(prover.verify().is_err());
1829 }
1830
1831 /// Verifies the out-of-circuit helper produces deterministic results.
1832 #[test]
1833 fn van_integrity_hash_deterministic() {
1834 let mut rng = OsRng;
1835
1836 let vpk_g_d = pallas::Base::random(&mut rng);
1837 let vpk_pk_d = pallas::Base::random(&mut rng);
1838 let val = pallas::Base::random(&mut rng);
1839 let round = pallas::Base::random(&mut rng);
1840 let auth = pallas::Base::random(&mut rng);
1841 let rand = pallas::Base::random(&mut rng);
1842
1843 let h1 = van_integrity_hash(vpk_g_d, vpk_pk_d, val, round, auth, rand);
1844 let h2 = van_integrity_hash(vpk_g_d, vpk_pk_d, val, round, auth, rand);
1845 assert_eq!(h1, h2);
1846
1847 // Changing any input changes the hash.
1848 let h3 = van_integrity_hash(
1849 pallas::Base::random(&mut rng),
1850 vpk_pk_d,
1851 val,
1852 round,
1853 auth,
1854 rand,
1855 );
1856 assert_ne!(h1, h3);
1857 }
1858
1859 // ================================================================
1860 // Condition 3 (Diversified Address Integrity / Address Ownership) tests
1861 //
1862 // These tests ensure the circuit rejects witnesses that violate
1863 // vpk_pk_d = [ivk_v] * vpk_g_d. Without condition 3 enabled, they
1864 // would pass (invalid address ownership would not be detected).
1865 // ================================================================
1866
1867 /// Using a different vsk in the circuit than was used to derive
1868 /// (vpk_g_d, vpk_pk_d) should fail condition 3 only: in-circuit
1869 /// [ivk']*vpk_g_d ≠ vpk_pk_d while VAN hash and nullifier stay valid.
1870 #[test]
1871 fn condition_3_wrong_vsk_fails() {
1872 let mut rng = OsRng;
1873
1874 let vsk = pallas::Scalar::random(&mut rng);
1875 let vsk_nk = pallas::Base::random(&mut rng);
1876 let rivk_v = pallas::Scalar::random(&mut rng);
1877 let (vpk_g_d_affine, vpk_pk_d_affine) = derive_voting_address(vsk, vsk_nk, rivk_v);
1878 let vpk_g_d_x = *vpk_g_d_affine.coordinates().unwrap().x();
1879 let vpk_pk_d_x = *vpk_pk_d_affine.coordinates().unwrap().x();
1880
1881 let total_note_value = pallas::Base::from(10_000u64);
1882 let voting_round_id = pallas::Base::random(&mut rng);
1883 let proposal_authority_old = pallas::Base::from(13u64);
1884 let proposal_id = 3u64;
1885 let van_comm_rand = pallas::Base::random(&mut rng);
1886
1887 let vote_authority_note_old = van_integrity_hash(
1888 vpk_g_d_x, vpk_pk_d_x, total_note_value, voting_round_id,
1889 proposal_authority_old, van_comm_rand,
1890 );
1891 let (auth_path, position, vote_comm_tree_root) =
1892 build_single_leaf_merkle_path(vote_authority_note_old);
1893 let van_nullifier = van_nullifier_hash(vsk_nk, voting_round_id, vote_authority_note_old);
1894 let one_shifted = pallas::Base::from(1u64 << proposal_id);
1895 let proposal_authority_new = proposal_authority_old - one_shifted;
1896 let vote_authority_note_new = van_integrity_hash(
1897 vpk_g_d_x, vpk_pk_d_x, total_note_value, voting_round_id,
1898 proposal_authority_new, van_comm_rand,
1899 );
1900
1901 let shares_u64: [u64; 16] = [625; 16];
1902 let (_ea_sk, ea_pk_point, ea_pk_affine) = generate_ea_keypair();
1903 let (enc_c1_x, enc_c2_x, enc_c1_y, enc_c2_y, randomness, share_blinds, shares_hash_val) =
1904 encrypt_shares(shares_u64, ea_pk_point);
1905
1906 let wrong_vsk = pallas::Scalar::random(&mut rng);
1907 assert_ne!(wrong_vsk, vsk, "test assumes distinct vsk with high probability");
1908 let alpha_v = pallas::Scalar::random(&mut rng);
1909 let g = pallas::Point::from(spend_auth_g_affine());
1910 let r_vpk = (g * vsk + g * alpha_v).to_affine();
1911 let r_vpk_x = *r_vpk.coordinates().unwrap().x();
1912 let r_vpk_y = *r_vpk.coordinates().unwrap().y();
1913
1914 let mut circuit = Circuit::with_van_witnesses(
1915 Value::known(auth_path),
1916 Value::known(position),
1917 Value::known(vpk_g_d_affine),
1918 Value::known(vpk_pk_d_affine),
1919 Value::known(total_note_value),
1920 Value::known(proposal_authority_old),
1921 Value::known(van_comm_rand),
1922 Value::known(vote_authority_note_old),
1923 Value::known(wrong_vsk),
1924 Value::known(rivk_v),
1925 Value::known(vsk_nk),
1926 Value::known(alpha_v),
1927 );
1928 circuit.one_shifted = Value::known(one_shifted);
1929 circuit.shares = shares_u64.map(|s| Value::known(pallas::Base::from(s)));
1930 circuit.enc_share_c1_x = enc_c1_x.map(Value::known);
1931 circuit.enc_share_c2_x = enc_c2_x.map(Value::known);
1932 circuit.enc_share_c1_y = enc_c1_y.map(Value::known);
1933 circuit.enc_share_c2_y = enc_c2_y.map(Value::known);
1934 circuit.share_blinds = share_blinds.map(Value::known);
1935 circuit.share_randomness = randomness.map(Value::known);
1936 circuit.ea_pk = Value::known(ea_pk_affine);
1937 let vc = set_condition_11(&mut circuit, shares_hash_val, proposal_id, voting_round_id);
1938
1939 let instance = Instance::from_parts(
1940 van_nullifier,
1941 r_vpk_x,
1942 r_vpk_y,
1943 vote_authority_note_new,
1944 vc,
1945 vote_comm_tree_root,
1946 pallas::Base::zero(),
1947 pallas::Base::from(proposal_id),
1948 voting_round_id,
1949 *ea_pk_affine.coordinates().unwrap().x(),
1950 *ea_pk_affine.coordinates().unwrap().y(),
1951 );
1952
1953 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
1954 assert!(prover.verify().is_err(), "condition 3 must reject wrong vsk");
1955 }
1956
1957 /// Using a vpk_pk_d that does not equal [ivk_v]*vpk_g_d should fail
1958 /// condition 3. Instance is built with a wrong vpk_pk_d for the VAN
1959 /// hash so condition 2 still passes; only condition 3 fails.
1960 #[test]
1961 fn condition_3_wrong_vpk_pk_d_fails() {
1962 let mut rng = OsRng;
1963
1964 let vsk = pallas::Scalar::random(&mut rng);
1965 let vsk_nk = pallas::Base::random(&mut rng);
1966 let rivk_v = pallas::Scalar::random(&mut rng);
1967 let (vpk_g_d_affine, _vpk_pk_d_correct) = derive_voting_address(vsk, vsk_nk, rivk_v);
1968 let vpk_g_d_x = *vpk_g_d_affine.coordinates().unwrap().x();
1969
1970 let wrong_vpk_pk_d_affine = (pallas::Point::generator() * pallas::Scalar::from(99999u64))
1971 .to_affine();
1972 let wrong_vpk_pk_d_x = *wrong_vpk_pk_d_affine.coordinates().unwrap().x();
1973
1974 let total_note_value = pallas::Base::from(10_000u64);
1975 let voting_round_id = pallas::Base::random(&mut rng);
1976 let proposal_authority_old = pallas::Base::from(13u64);
1977 let proposal_id = 3u64;
1978 let van_comm_rand = pallas::Base::random(&mut rng);
1979
1980 let vote_authority_note_old = van_integrity_hash(
1981 vpk_g_d_x,
1982 wrong_vpk_pk_d_x,
1983 total_note_value,
1984 voting_round_id,
1985 proposal_authority_old,
1986 van_comm_rand,
1987 );
1988 let (auth_path, position, vote_comm_tree_root) =
1989 build_single_leaf_merkle_path(vote_authority_note_old);
1990 let van_nullifier = van_nullifier_hash(vsk_nk, voting_round_id, vote_authority_note_old);
1991 let one_shifted = pallas::Base::from(1u64 << proposal_id);
1992 let proposal_authority_new = proposal_authority_old - one_shifted;
1993 let vote_authority_note_new = van_integrity_hash(
1994 vpk_g_d_x,
1995 wrong_vpk_pk_d_x,
1996 total_note_value,
1997 voting_round_id,
1998 proposal_authority_new,
1999 van_comm_rand,
2000 );
2001
2002 let shares_u64: [u64; 16] = [625; 16];
2003 let (_ea_sk, ea_pk_point, ea_pk_affine) = generate_ea_keypair();
2004 let (enc_c1_x, enc_c2_x, enc_c1_y, enc_c2_y, randomness, share_blinds, shares_hash_val) =
2005 encrypt_shares(shares_u64, ea_pk_point);
2006
2007 let alpha_v = pallas::Scalar::random(&mut rng);
2008 let g = pallas::Point::from(spend_auth_g_affine());
2009 let r_vpk = (g * vsk + g * alpha_v).to_affine();
2010 let r_vpk_x = *r_vpk.coordinates().unwrap().x();
2011 let r_vpk_y = *r_vpk.coordinates().unwrap().y();
2012
2013 let mut circuit = Circuit::with_van_witnesses(
2014 Value::known(auth_path),
2015 Value::known(position),
2016 Value::known(vpk_g_d_affine),
2017 Value::known(wrong_vpk_pk_d_affine),
2018 Value::known(total_note_value),
2019 Value::known(proposal_authority_old),
2020 Value::known(van_comm_rand),
2021 Value::known(vote_authority_note_old),
2022 Value::known(vsk),
2023 Value::known(rivk_v),
2024 Value::known(vsk_nk),
2025 Value::known(alpha_v),
2026 );
2027 circuit.one_shifted = Value::known(one_shifted);
2028 circuit.shares = shares_u64.map(|s| Value::known(pallas::Base::from(s)));
2029 circuit.enc_share_c1_x = enc_c1_x.map(Value::known);
2030 circuit.enc_share_c2_x = enc_c2_x.map(Value::known);
2031 circuit.enc_share_c1_y = enc_c1_y.map(Value::known);
2032 circuit.enc_share_c2_y = enc_c2_y.map(Value::known);
2033 circuit.share_blinds = share_blinds.map(Value::known);
2034 circuit.share_randomness = randomness.map(Value::known);
2035 circuit.ea_pk = Value::known(ea_pk_affine);
2036 let vc = set_condition_11(&mut circuit, shares_hash_val, proposal_id, voting_round_id);
2037
2038 let instance = Instance::from_parts(
2039 van_nullifier,
2040 r_vpk_x,
2041 r_vpk_y,
2042 vote_authority_note_new,
2043 vc,
2044 vote_comm_tree_root,
2045 pallas::Base::zero(),
2046 pallas::Base::from(proposal_id),
2047 voting_round_id,
2048 *ea_pk_affine.coordinates().unwrap().x(),
2049 *ea_pk_affine.coordinates().unwrap().y(),
2050 );
2051
2052 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2053 assert!(prover.verify().is_err(), "condition 3 must reject wrong vpk_pk_d");
2054 }
2055
2056 // ================================================================
2057 // Condition 4 (Spend Authority) tests
2058 // ================================================================
2059
2060 /// Wrong r_vpk public input should fail condition 4.
2061 #[test]
2062 fn condition_4_wrong_r_vpk_fails() {
2063 let (circuit, mut instance) = make_test_data();
2064
2065 instance.r_vpk_x = pallas::Base::random(&mut OsRng);
2066
2067 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2068 assert!(prover.verify().is_err(), "condition 4 must reject wrong r_vpk");
2069 }
2070
2071 // ================================================================
2072 // Condition 5 (VAN Nullifier Integrity) tests
2073 // ================================================================
2074
2075 /// Wrong VAN_NULLIFIER public input should fail condition 5.
2076 #[test]
2077 fn van_nullifier_wrong_public_input_fails() {
2078 let (circuit, mut instance) = make_test_data();
2079
2080 // Corrupt the VAN nullifier public input.
2081 instance.van_nullifier = pallas::Base::random(&mut OsRng);
2082
2083 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2084
2085 // Should fail: circuit-derived nullifier ≠ corrupted instance value.
2086 assert!(prover.verify().is_err());
2087 }
2088
2089 /// Using a different vsk_nk in the circuit than was used to compute
2090 /// the instance nullifier should fail condition 5.
2091 /// Note: since vsk_nk is also used in CommitIvk (condition 3), the
2092 /// wrong value also breaks condition 3 — but the test still verifies
2093 /// that the proof fails as expected.
2094 #[test]
2095 fn van_nullifier_wrong_vsk_nk_fails() {
2096 let mut rng = OsRng;
2097
2098 // Derive proper keys with the CORRECT vsk_nk.
2099 let vsk = pallas::Scalar::random(&mut rng);
2100 let vsk_nk = pallas::Base::random(&mut rng);
2101 let rivk_v = pallas::Scalar::random(&mut rng);
2102 let (vpk_g_d_affine, vpk_pk_d_affine) = derive_voting_address(vsk, vsk_nk, rivk_v);
2103 let vpk_g_d_x = *vpk_g_d_affine.coordinates().unwrap().x();
2104 let vpk_pk_d_x = *vpk_pk_d_affine.coordinates().unwrap().x();
2105
2106 let total_note_value = pallas::Base::from(10_000u64);
2107 let voting_round_id = pallas::Base::random(&mut rng);
2108 let proposal_authority_old = pallas::Base::from(5u64); // bits 0 and 2 set
2109 let van_comm_rand = pallas::Base::random(&mut rng);
2110 let proposal_id = 0u64; // vote on proposal 0 so one_shifted = 1, new = 4
2111
2112 let vote_authority_note_old = van_integrity_hash(
2113 vpk_g_d_x, vpk_pk_d_x, total_note_value, voting_round_id,
2114 proposal_authority_old, van_comm_rand,
2115 );
2116 let (auth_path, position, vote_comm_tree_root) =
2117 build_single_leaf_merkle_path(vote_authority_note_old);
2118 let van_nullifier = van_nullifier_hash(vsk_nk, voting_round_id, vote_authority_note_old);
2119 let one_shifted = pallas::Base::from(1u64 << proposal_id);
2120 let proposal_authority_new = proposal_authority_old - one_shifted;
2121 let vote_authority_note_new = van_integrity_hash(
2122 vpk_g_d_x, vpk_pk_d_x, total_note_value, voting_round_id,
2123 proposal_authority_new, van_comm_rand,
2124 );
2125
2126 // Use a DIFFERENT vsk_nk in the circuit.
2127 let wrong_vsk_nk = pallas::Base::random(&mut rng);
2128 let alpha_v = pallas::Scalar::random(&mut rng);
2129 let g = pallas::Point::from(spend_auth_g_affine());
2130 let r_vpk = (g * vsk + g * alpha_v).to_affine();
2131 let r_vpk_x = *r_vpk.coordinates().unwrap().x();
2132 let r_vpk_y = *r_vpk.coordinates().unwrap().y();
2133
2134 // Shares that sum to total_note_value (conditions 8 + 9).
2135 let shares_u64: [u64; 16] = [625; 16];
2136
2137 // Condition 11: real El Gamal encryption.
2138 let (_ea_sk, ea_pk_point, ea_pk_affine) = generate_ea_keypair();
2139 let (enc_c1_x, enc_c2_x, enc_c1_y, enc_c2_y, randomness, share_blinds, shares_hash_val) =
2140 encrypt_shares(shares_u64, ea_pk_point);
2141
2142 let mut circuit = Circuit::with_van_witnesses(
2143 Value::known(auth_path),
2144 Value::known(position),
2145 Value::known(vpk_g_d_affine),
2146 Value::known(vpk_pk_d_affine),
2147 Value::known(total_note_value),
2148 Value::known(proposal_authority_old),
2149 Value::known(van_comm_rand),
2150 Value::known(vote_authority_note_old),
2151 Value::known(vsk),
2152 Value::known(rivk_v),
2153 Value::known(wrong_vsk_nk),
2154 Value::known(alpha_v),
2155 );
2156 circuit.one_shifted = Value::known(one_shifted);
2157 circuit.shares = shares_u64.map(|s| Value::known(pallas::Base::from(s)));
2158 circuit.enc_share_c1_x = enc_c1_x.map(Value::known);
2159 circuit.enc_share_c2_x = enc_c2_x.map(Value::known);
2160 circuit.enc_share_c1_y = enc_c1_y.map(Value::known);
2161 circuit.enc_share_c2_y = enc_c2_y.map(Value::known);
2162 circuit.share_blinds = share_blinds.map(Value::known);
2163 circuit.share_randomness = randomness.map(Value::known);
2164 circuit.ea_pk = Value::known(ea_pk_affine);
2165 let vc = set_condition_11(&mut circuit, shares_hash_val, proposal_id, voting_round_id);
2166
2167 let instance = Instance::from_parts(
2168 van_nullifier,
2169 r_vpk_x,
2170 r_vpk_y,
2171 vote_authority_note_new,
2172 vc,
2173 vote_comm_tree_root,
2174 pallas::Base::zero(),
2175 pallas::Base::from(proposal_id),
2176 voting_round_id,
2177 *ea_pk_affine.coordinates().unwrap().x(),
2178 *ea_pk_affine.coordinates().unwrap().y(),
2179 );
2180
2181 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2182 // Should fail: circuit computes Poseidon(wrong_vsk_nk, inner_hash)
2183 // which ≠ the instance van_nullifier (computed with correct vsk_nk).
2184 // Also fails condition 3 since wrong_vsk_nk breaks CommitIvk derivation.
2185 assert!(prover.verify().is_err());
2186 }
2187
2188 /// Verifies the out-of-circuit nullifier helper produces deterministic results.
2189 #[test]
2190 fn van_nullifier_hash_deterministic() {
2191 let mut rng = OsRng;
2192
2193 let nk = pallas::Base::random(&mut rng);
2194 let round = pallas::Base::random(&mut rng);
2195 let van = pallas::Base::random(&mut rng);
2196
2197 let h1 = van_nullifier_hash(nk, round, van);
2198 let h2 = van_nullifier_hash(nk, round, van);
2199 assert_eq!(h1, h2);
2200
2201 // Changing any input changes the hash.
2202 let h3 = van_nullifier_hash(pallas::Base::random(&mut rng), round, van);
2203 assert_ne!(h1, h3);
2204 }
2205
2206 /// Verifies the domain tag is non-zero and deterministic.
2207 #[test]
2208 fn domain_van_nullifier_deterministic() {
2209 let d1 = domain_van_nullifier();
2210 let d2 = domain_van_nullifier();
2211 assert_eq!(d1, d2);
2212
2213 // Must differ from DOMAIN_VAN (which is 0).
2214 assert_ne!(d1, pallas::Base::zero());
2215 }
2216
2217 // ================================================================
2218 // Condition 6 (Proposal Authority Decrement) tests
2219 // ================================================================
2220
2221 /// Proposal authority with only bit 0 set (value 1): vote on proposal 0, new = 0.
2222 #[test]
2223 fn proposal_authority_decrement_minimum_valid() {
2224 // proposal_id = 0 is now forbidden (sentinel value); use the next smallest valid id.
2225 // Authority = 2 = 0b0010 has exactly bit 1 set, so proposal_id = 1 is valid.
2226 // After decrement: proposal_authority_new = 0 (minimum possible outcome).
2227 let (circuit, instance) =
2228 make_test_data_with_authority_and_proposal(pallas::Base::from(2u64), 1);
2229
2230 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2231 assert_eq!(prover.verify(), Ok(()));
2232 }
2233
2234 /// With proposal_authority_old = 0, the selected bit is 0 so the
2235 /// "run_selected = 1" constraint (selected bit was set) fails.
2236 #[test]
2237 fn proposal_authority_zero_fails() {
2238 let (circuit, instance) = make_test_data_with_authority(pallas::Base::zero());
2239
2240 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2241
2242 assert!(prover.verify().is_err());
2243 }
2244
2245 /// proposal_id = 0 is the dummy sentinel value and must be rejected (Cond 6, gate).
2246 #[test]
2247 fn proposal_id_zero_fails() {
2248 // Authority = 1 = 0b0001 has bit 0 set, so this is otherwise a structurally
2249 // valid decrement — the only reason it must fail is the non-zero gate.
2250 let (circuit, instance) =
2251 make_test_data_with_authority_and_proposal(pallas::Base::one(), 0);
2252
2253 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2254 assert!(prover.verify().is_err(), "proposal_id = 0 must be rejected");
2255 }
2256
2257 /// Full authority (65535), proposal_id 1 → new = 65533 (e2e scenario).
2258 #[test]
2259 fn proposal_authority_full_authority_proposal_1_passes() {
2260 const MAX_PROPOSAL_AUTHORITY: u64 = 65535;
2261 let (circuit, instance) = make_test_data_with_authority_and_proposal(
2262 pallas::Base::from(MAX_PROPOSAL_AUTHORITY),
2263 1,
2264 );
2265
2266 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2267 assert_eq!(prover.verify(), Ok(()));
2268 }
2269
2270 /// Wrong vote_authority_note_new (e.g. not clearing the bit) fails condition 6.
2271 #[test]
2272 fn proposal_authority_wrong_new_fails() {
2273 let (circuit, mut instance) =
2274 make_test_data_with_authority_and_proposal(pallas::Base::from(65535u64), 1);
2275
2276 instance.vote_authority_note_new = pallas::Base::random(&mut OsRng);
2277
2278 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2279 assert!(prover.verify().is_err());
2280 }
2281
2282 /// authority=4 (0b0100, bit 2 set only), proposal_id=1 (bit 1 absent) →
2283 /// run_selected=0 at the terminal row, so "run_selected = 1" fails.
2284 /// Uses proposal_id=1 (not 0) to isolate this constraint from the
2285 /// proposal_id != 0 sentinel gate.
2286 #[test]
2287 fn proposal_authority_bit_not_set_fails() {
2288 let (circuit, instance) =
2289 make_test_data_with_authority_and_proposal(pallas::Base::from(4u64), 1);
2290
2291 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2292 assert!(prover.verify().is_err());
2293 }
2294
2295 /// Condition 6 enforces run_sel = 1 (exactly one selector active) at the last bit row;
2296 /// see CONDITION_6_RUN_SEL_FIX.md. This test runs a valid proof (one selector) and
2297 /// verifies it passes; a zero-selector witness would be rejected by that gate.
2298 #[test]
2299 fn proposal_authority_condition6_run_sel_constraint() {
2300 let (circuit, instance) =
2301 make_test_data_with_authority_and_proposal(pallas::Base::from(3u64), 1);
2302
2303 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2304 assert_eq!(prover.verify(), Ok(()));
2305 }
2306
2307 /// proposal_authority_old = 65536 = 2^16 lies outside the valid 16-bit bitmask
2308 /// range [0, 65535]. The authority_decrement gadget decomposes the value into
2309 /// exactly 16 bits (positions 0–15); a value with bit 16 set cannot be represented
2310 /// in that decomposition and must be rejected by the range check.
2311 #[test]
2312 fn proposal_authority_exceeds_16_bits_fails() {
2313 // 65536 = 2^16 is the first value not representable as a 16-bit bitmask.
2314 let (circuit, instance) =
2315 make_test_data_with_authority(pallas::Base::from(65536u64));
2316 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2317 assert!(
2318 prover.verify().is_err(),
2319 "authority > 65535 must be rejected by the 16-bit bit decomposition"
2320 );
2321 }
2322
2323 // ================================================================
2324 // Condition 7 (New VAN Integrity) tests
2325 // ================================================================
2326
2327 /// Wrong vote_authority_note_new public input should fail condition 7.
2328 #[test]
2329 fn new_van_integrity_wrong_public_input_fails() {
2330 let (circuit, mut instance) = make_test_data();
2331
2332 // Corrupt the new VAN public input.
2333 instance.vote_authority_note_new = pallas::Base::random(&mut OsRng);
2334
2335 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2336
2337 // Should fail: circuit-derived new VAN ≠ corrupted instance value.
2338 assert!(prover.verify().is_err());
2339 }
2340
2341 /// New VAN integrity with a large (but valid) 16-bit proposal authority.
2342 /// Authority 0xFFF8 has bits 3..15 set; voting on proposal 3 gives new = 0xFFF0.
2343 #[test]
2344 fn new_van_integrity_large_authority() {
2345 let (circuit, instance) =
2346 make_test_data_with_authority(pallas::Base::from(0xFFF8u64));
2347
2348 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2349 assert_eq!(prover.verify(), Ok(()));
2350 }
2351
2352 // ================================================================
2353 // Condition 1 (VAN Membership) tests
2354 // ================================================================
2355
2356 /// Wrong vote_comm_tree_root in the instance should fail condition 1.
2357 #[test]
2358 fn van_membership_wrong_root_fails() {
2359 let (circuit, mut instance) = make_test_data();
2360
2361 // Corrupt the tree root.
2362 instance.vote_comm_tree_root = pallas::Base::random(&mut OsRng);
2363
2364 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2365 assert!(prover.verify().is_err());
2366 }
2367
2368 /// A VAN at a non-zero position in the tree should verify.
2369 #[test]
2370 fn van_membership_nonzero_position() {
2371 let mut rng = OsRng;
2372
2373 // Derive proper voting key hierarchy.
2374 let vsk = pallas::Scalar::random(&mut rng);
2375 let vsk_nk = pallas::Base::random(&mut rng);
2376 let rivk_v = pallas::Scalar::random(&mut rng);
2377 let (vpk_g_d_affine, vpk_pk_d_affine) = derive_voting_address(vsk, vsk_nk, rivk_v);
2378 let vpk_g_d_x = *vpk_g_d_affine.coordinates().unwrap().x();
2379 let vpk_pk_d_x = *vpk_pk_d_affine.coordinates().unwrap().x();
2380
2381 let total_note_value = pallas::Base::from(10_000u64);
2382 let voting_round_id = pallas::Base::random(&mut rng);
2383 let proposal_authority_old = pallas::Base::from(5u64); // bits 0 and 2 set
2384 // proposal_id = 0 is now forbidden (sentinel); use proposal_id = 2 (bit 2 is set in 5).
2385 let proposal_id = 2u64;
2386 let van_comm_rand = pallas::Base::random(&mut rng);
2387
2388 let vote_authority_note_old = van_integrity_hash(
2389 vpk_g_d_x, vpk_pk_d_x, total_note_value, voting_round_id,
2390 proposal_authority_old, van_comm_rand,
2391 );
2392
2393 // Place the leaf at position 7 (binary: ...0111).
2394 let position: u32 = 7;
2395 let mut empty_roots = [pallas::Base::zero(); VOTE_COMM_TREE_DEPTH];
2396 empty_roots[0] = poseidon_hash_2(pallas::Base::zero(), pallas::Base::zero());
2397 for i in 1..VOTE_COMM_TREE_DEPTH {
2398 empty_roots[i] = poseidon_hash_2(empty_roots[i - 1], empty_roots[i - 1]);
2399 }
2400 let auth_path = empty_roots;
2401 let mut current = vote_authority_note_old;
2402 for i in 0..VOTE_COMM_TREE_DEPTH {
2403 if (position >> i) & 1 == 0 {
2404 current = poseidon_hash_2(current, auth_path[i]);
2405 } else {
2406 current = poseidon_hash_2(auth_path[i], current);
2407 }
2408 }
2409 let vote_comm_tree_root = current;
2410
2411 let van_nullifier = van_nullifier_hash(vsk_nk, voting_round_id, vote_authority_note_old);
2412 let one_shifted = pallas::Base::from(1u64 << proposal_id);
2413 let proposal_authority_new = proposal_authority_old - one_shifted;
2414 let vote_authority_note_new = van_integrity_hash(
2415 vpk_g_d_x, vpk_pk_d_x, total_note_value, voting_round_id,
2416 proposal_authority_new, van_comm_rand,
2417 );
2418
2419 let alpha_v = pallas::Scalar::random(&mut rng);
2420 let g = pallas::Point::from(spend_auth_g_affine());
2421 let r_vpk = (g * vsk + g * alpha_v).to_affine();
2422 let r_vpk_x = *r_vpk.coordinates().unwrap().x();
2423 let r_vpk_y = *r_vpk.coordinates().unwrap().y();
2424
2425 // Shares that sum to total_note_value (conditions 8 + 9).
2426 let shares_u64: [u64; 16] = [625; 16];
2427
2428 // Condition 11: real El Gamal encryption.
2429 let (_ea_sk, ea_pk_point, ea_pk_affine) = generate_ea_keypair();
2430 let (enc_c1_x, enc_c2_x, enc_c1_y, enc_c2_y, randomness, share_blinds, shares_hash_val) =
2431 encrypt_shares(shares_u64, ea_pk_point);
2432
2433 let mut circuit = Circuit::with_van_witnesses(
2434 Value::known(auth_path),
2435 Value::known(position),
2436 Value::known(vpk_g_d_affine),
2437 Value::known(vpk_pk_d_affine),
2438 Value::known(total_note_value),
2439 Value::known(proposal_authority_old),
2440 Value::known(van_comm_rand),
2441 Value::known(vote_authority_note_old),
2442 Value::known(vsk),
2443 Value::known(rivk_v),
2444 Value::known(vsk_nk),
2445 Value::known(alpha_v),
2446 );
2447 circuit.one_shifted = Value::known(one_shifted);
2448 circuit.shares = shares_u64.map(|s| Value::known(pallas::Base::from(s)));
2449 circuit.enc_share_c1_x = enc_c1_x.map(Value::known);
2450 circuit.enc_share_c2_x = enc_c2_x.map(Value::known);
2451 circuit.enc_share_c1_y = enc_c1_y.map(Value::known);
2452 circuit.enc_share_c2_y = enc_c2_y.map(Value::known);
2453 circuit.share_blinds = share_blinds.map(Value::known);
2454 circuit.share_randomness = randomness.map(Value::known);
2455 circuit.ea_pk = Value::known(ea_pk_affine);
2456 let vc = set_condition_11(&mut circuit, shares_hash_val, proposal_id, voting_round_id);
2457
2458 let instance = Instance::from_parts(
2459 van_nullifier,
2460 r_vpk_x,
2461 r_vpk_y,
2462 vote_authority_note_new,
2463 vc,
2464 vote_comm_tree_root,
2465 pallas::Base::zero(),
2466 pallas::Base::from(proposal_id),
2467 voting_round_id,
2468 *ea_pk_affine.coordinates().unwrap().x(),
2469 *ea_pk_affine.coordinates().unwrap().y(),
2470 );
2471
2472 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2473 assert_eq!(prover.verify(), Ok(()));
2474 }
2475
2476 /// Poseidon hash-2 helper is deterministic.
2477 #[test]
2478 fn poseidon_hash_2_deterministic() {
2479 let mut rng = OsRng;
2480 let a = pallas::Base::random(&mut rng);
2481 let b = pallas::Base::random(&mut rng);
2482
2483 assert_eq!(poseidon_hash_2(a, b), poseidon_hash_2(a, b));
2484 // Non-commutative.
2485 assert_ne!(poseidon_hash_2(a, b), poseidon_hash_2(b, a));
2486 }
2487
2488 // ================================================================
2489 // Condition 8 (Shares Sum Correctness) tests
2490 // ================================================================
2491
2492 /// Shares that do NOT sum to total_note_value should fail condition 8.
2493 #[test]
2494 fn shares_sum_wrong_total_fails() {
2495 let (mut circuit, instance) = make_test_data();
2496
2497 // Corrupt shares[3] so the sum no longer equals total_note_value.
2498 // Use a small value that still passes condition 9's range check,
2499 // isolating the condition 8 failure.
2500 circuit.shares[3] = Value::known(pallas::Base::from(999u64));
2501
2502 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2503 // Should fail: shares sum ≠ total_note_value.
2504 assert!(prover.verify().is_err());
2505 }
2506
2507 // ================================================================
2508 // Condition 9 (Shares Range) tests
2509 // ================================================================
2510
2511 /// A share at the maximum valid value (2^30 - 1) should pass.
2512 #[test]
2513 fn shares_range_max_valid() {
2514 let max_share = pallas::Base::from((1u64 << 30) - 1); // 1,073,741,823
2515 let total = (0..16).fold(pallas::Base::zero(), |acc, _| acc + max_share);
2516
2517 let mut rng = OsRng;
2518 // Derive proper voting key hierarchy.
2519 let vsk = pallas::Scalar::random(&mut rng);
2520 let vsk_nk = pallas::Base::random(&mut rng);
2521 let rivk_v = pallas::Scalar::random(&mut rng);
2522 let (vpk_g_d_affine, vpk_pk_d_affine) = derive_voting_address(vsk, vsk_nk, rivk_v);
2523 let vpk_g_d_x = *vpk_g_d_affine.coordinates().unwrap().x();
2524 let vpk_pk_d_x = *vpk_pk_d_affine.coordinates().unwrap().x();
2525
2526 let voting_round_id = pallas::Base::random(&mut rng);
2527 let proposal_authority_old = pallas::Base::from(5u64); // bits 0 and 2 set
2528 // proposal_id = 0 is now forbidden (sentinel); use proposal_id = 2 (bit 2 is set in 5).
2529 let proposal_id = 2u64;
2530 let van_comm_rand = pallas::Base::random(&mut rng);
2531
2532 let vote_authority_note_old = van_integrity_hash(
2533 vpk_g_d_x, vpk_pk_d_x, total, voting_round_id,
2534 proposal_authority_old, van_comm_rand,
2535 );
2536 let (auth_path, position, vote_comm_tree_root) =
2537 build_single_leaf_merkle_path(vote_authority_note_old);
2538 let van_nullifier = van_nullifier_hash(vsk_nk, voting_round_id, vote_authority_note_old);
2539 let one_shifted = pallas::Base::from(1u64 << proposal_id);
2540 let proposal_authority_new = proposal_authority_old - one_shifted;
2541 let vote_authority_note_new = van_integrity_hash(
2542 vpk_g_d_x, vpk_pk_d_x, total, voting_round_id,
2543 proposal_authority_new, van_comm_rand,
2544 );
2545
2546 // Condition 11: real El Gamal encryption with max-value shares.
2547 let max_share_u64 = (1u64 << 30) - 1;
2548 let shares_u64: [u64; 16] = [max_share_u64; 16];
2549 let (_ea_sk, ea_pk_point, ea_pk_affine) = generate_ea_keypair();
2550 let (enc_c1_x, enc_c2_x, enc_c1_y, enc_c2_y, randomness, share_blinds, shares_hash_val) =
2551 encrypt_shares(shares_u64, ea_pk_point);
2552
2553 let alpha_v = pallas::Scalar::random(&mut rng);
2554 let g = pallas::Point::from(spend_auth_g_affine());
2555 let r_vpk = (g * vsk + g * alpha_v).to_affine();
2556 let r_vpk_x = *r_vpk.coordinates().unwrap().x();
2557 let r_vpk_y = *r_vpk.coordinates().unwrap().y();
2558
2559 let mut circuit = Circuit::with_van_witnesses(
2560 Value::known(auth_path),
2561 Value::known(position),
2562 Value::known(vpk_g_d_affine),
2563 Value::known(vpk_pk_d_affine),
2564 Value::known(total),
2565 Value::known(proposal_authority_old),
2566 Value::known(van_comm_rand),
2567 Value::known(vote_authority_note_old),
2568 Value::known(vsk),
2569 Value::known(rivk_v),
2570 Value::known(vsk_nk),
2571 Value::known(alpha_v),
2572 );
2573 circuit.one_shifted = Value::known(one_shifted);
2574 circuit.shares = [Value::known(max_share); 16];
2575 circuit.enc_share_c1_x = enc_c1_x.map(Value::known);
2576 circuit.enc_share_c2_x = enc_c2_x.map(Value::known);
2577 circuit.enc_share_c1_y = enc_c1_y.map(Value::known);
2578 circuit.enc_share_c2_y = enc_c2_y.map(Value::known);
2579 circuit.share_blinds = share_blinds.map(Value::known);
2580 circuit.share_randomness = randomness.map(Value::known);
2581 circuit.ea_pk = Value::known(ea_pk_affine);
2582 let vc = set_condition_11(&mut circuit, shares_hash_val, proposal_id, voting_round_id);
2583
2584 let instance = Instance::from_parts(
2585 van_nullifier,
2586 r_vpk_x,
2587 r_vpk_y,
2588 vote_authority_note_new,
2589 vc,
2590 vote_comm_tree_root,
2591 pallas::Base::zero(),
2592 pallas::Base::from(proposal_id),
2593 voting_round_id,
2594 *ea_pk_affine.coordinates().unwrap().x(),
2595 *ea_pk_affine.coordinates().unwrap().y(),
2596 );
2597
2598 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2599 assert_eq!(prover.verify(), Ok(()));
2600 }
2601
2602 /// A share at exactly 2^30 should fail the range check.
2603 #[test]
2604 fn shares_range_overflow_fails() {
2605 let (mut circuit, instance) = make_test_data();
2606
2607 // Set share_0 to 2^30 (one above the max valid value).
2608 // This will fail condition 9 AND condition 8 (sum mismatch),
2609 // but the important thing is the circuit rejects it.
2610 circuit.shares[0] = Value::known(pallas::Base::from(1u64 << 30));
2611
2612 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2613 assert!(prover.verify().is_err());
2614 }
2615
2616 /// A share that is a large field element (simulating underflow
2617 /// from subtraction) should fail the range check.
2618 #[test]
2619 fn shares_range_field_wrap_fails() {
2620 let (mut circuit, instance) = make_test_data();
2621
2622 // Set share_0 to p - 1 (a wrapped negative value).
2623 // The 10-bit decomposition will produce a huge residual.
2624 circuit.shares[0] = Value::known(-pallas::Base::one());
2625
2626 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2627 assert!(prover.verify().is_err());
2628 }
2629
2630 /// Shares that sum correctly to total_note_value but with shares[0] = 2^30
2631 /// (one above the per-share maximum). Condition 8 (sum check) passes because
2632 /// total_note_value is set to match the sum. Condition 9 (range check) must
2633 /// still reject the individual overflow, confirming it checks each share
2634 /// independently — a correct sum does not bypass the per-share range gate.
2635 #[test]
2636 fn shares_range_single_overflow_correct_sum_fails() {
2637 let mut rng = OsRng;
2638
2639 let overflow_share = pallas::Base::from(1u64 << 30); // 2^30 — just above [0, 2^30)
2640 let normal_share_u64 = 625u64;
2641 // total_note_value = 2^30 + 15 * 625 so sum(shares) == total_note_value.
2642 let total_note_value = overflow_share + pallas::Base::from(15u64 * normal_share_u64);
2643
2644 let vsk = pallas::Scalar::random(&mut rng);
2645 let vsk_nk = pallas::Base::random(&mut rng);
2646 let rivk_v = pallas::Scalar::random(&mut rng);
2647 let alpha_v = pallas::Scalar::random(&mut rng);
2648 let (vpk_g_d_affine, vpk_pk_d_affine) = derive_voting_address(vsk, vsk_nk, rivk_v);
2649 let vpk_g_d_x = *vpk_g_d_affine.coordinates().unwrap().x();
2650 let vpk_pk_d_x = *vpk_pk_d_affine.coordinates().unwrap().x();
2651
2652 let voting_round_id = pallas::Base::random(&mut rng);
2653 let proposal_authority_old = pallas::Base::from(13u64); // bit 3 set
2654 let proposal_id = TEST_PROPOSAL_ID;
2655 let van_comm_rand = pallas::Base::random(&mut rng);
2656
2657 let vote_authority_note_old = van_integrity_hash(
2658 vpk_g_d_x, vpk_pk_d_x, total_note_value, voting_round_id,
2659 proposal_authority_old, van_comm_rand,
2660 );
2661 let (auth_path, position, vote_comm_tree_root) =
2662 build_single_leaf_merkle_path(vote_authority_note_old);
2663 let van_nullifier = van_nullifier_hash(vsk_nk, voting_round_id, vote_authority_note_old);
2664 let one_shifted = pallas::Base::from(1u64 << proposal_id);
2665 let proposal_authority_new = proposal_authority_old - one_shifted;
2666 let vote_authority_note_new = van_integrity_hash(
2667 vpk_g_d_x, vpk_pk_d_x, total_note_value, voting_round_id,
2668 proposal_authority_new, van_comm_rand,
2669 );
2670
2671 // shares[0] overflows (2^30); shares[1..16] are valid (625 each).
2672 // The encryption is computed with these exact values so condition 11 is consistent.
2673 let shares_u64: [u64; 16] = {
2674 let mut arr = [normal_share_u64; 16];
2675 arr[0] = 1u64 << 30;
2676 arr
2677 };
2678 let (_ea_sk, ea_pk_point, ea_pk_affine) = generate_ea_keypair();
2679 let (enc_c1_x, enc_c2_x, enc_c1_y, enc_c2_y, randomness, share_blinds, shares_hash_val) =
2680 encrypt_shares(shares_u64, ea_pk_point);
2681
2682 let g = pallas::Point::from(spend_auth_g_affine());
2683 let r_vpk = (g * vsk + g * alpha_v).to_affine();
2684
2685 let mut circuit = Circuit::with_van_witnesses(
2686 Value::known(auth_path),
2687 Value::known(position),
2688 Value::known(vpk_g_d_affine),
2689 Value::known(vpk_pk_d_affine),
2690 Value::known(total_note_value),
2691 Value::known(proposal_authority_old),
2692 Value::known(van_comm_rand),
2693 Value::known(vote_authority_note_old),
2694 Value::known(vsk),
2695 Value::known(rivk_v),
2696 Value::known(vsk_nk),
2697 Value::known(alpha_v),
2698 );
2699 circuit.one_shifted = Value::known(one_shifted);
2700 circuit.shares = shares_u64.map(|s| Value::known(pallas::Base::from(s)));
2701 circuit.enc_share_c1_x = enc_c1_x.map(Value::known);
2702 circuit.enc_share_c2_x = enc_c2_x.map(Value::known);
2703 circuit.enc_share_c1_y = enc_c1_y.map(Value::known);
2704 circuit.enc_share_c2_y = enc_c2_y.map(Value::known);
2705 circuit.share_blinds = share_blinds.map(Value::known);
2706 circuit.share_randomness = randomness.map(Value::known);
2707 circuit.ea_pk = Value::known(ea_pk_affine);
2708
2709 let vote_commitment =
2710 set_condition_11(&mut circuit, shares_hash_val, proposal_id, voting_round_id);
2711
2712 let instance = Instance::from_parts(
2713 van_nullifier,
2714 *r_vpk.coordinates().unwrap().x(),
2715 *r_vpk.coordinates().unwrap().y(),
2716 vote_authority_note_new,
2717 vote_commitment,
2718 vote_comm_tree_root,
2719 pallas::Base::zero(),
2720 pallas::Base::from(proposal_id),
2721 voting_round_id,
2722 *ea_pk_affine.coordinates().unwrap().x(),
2723 *ea_pk_affine.coordinates().unwrap().y(),
2724 );
2725
2726 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2727 // Condition 8 (sum check) passes: shares sum to total_note_value.
2728 // Condition 9 (range check) must reject shares[0] = 2^30 regardless.
2729 assert!(
2730 prover.verify().is_err(),
2731 "range check must reject a share equal to 2^30 even when the total sum is correct"
2732 );
2733 }
2734
2735 // ================================================================
2736 // Condition 10 (Shares Hash Integrity) tests
2737 // ================================================================
2738
2739 /// Valid enc_share witnesses with matching shares_hash should pass.
2740 #[test]
2741 fn shares_hash_valid_proof() {
2742 let (circuit, instance) = make_test_data();
2743
2744 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2745 assert_eq!(prover.verify(), Ok(()));
2746 }
2747
2748 /// A corrupted enc_share_c1_x[0] should cause condition 10 failure:
2749 /// the in-circuit hash won't match the VOTE_COMMITMENT instance.
2750 #[test]
2751 fn shares_hash_wrong_enc_share_fails() {
2752 let (mut circuit, instance) = make_test_data();
2753
2754 // Corrupt one enc_share component — the Poseidon hash will
2755 // change, so it won't match the instance's vote_commitment.
2756 circuit.enc_share_c1_x[0] = Value::known(pallas::Base::random(&mut OsRng));
2757
2758 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2759 assert!(prover.verify().is_err());
2760 }
2761
2762 /// A wrong vote_commitment instance value (shares_hash mismatch)
2763 /// should fail, even with correct enc_share witnesses.
2764 #[test]
2765 fn shares_hash_wrong_instance_fails() {
2766 let (circuit, mut instance) = make_test_data();
2767
2768 // Supply a random (wrong) vote_commitment in the instance.
2769 instance.vote_commitment = pallas::Base::random(&mut OsRng);
2770
2771 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2772 assert!(prover.verify().is_err());
2773 }
2774
2775 /// Verifies the out-of-circuit shares_hash helper is deterministic.
2776 #[test]
2777 fn shares_hash_deterministic() {
2778 let mut rng = OsRng;
2779
2780 let blinds: [pallas::Base; 16] =
2781 core::array::from_fn(|_| pallas::Base::random(&mut rng));
2782 let c1_x: [pallas::Base; 16] =
2783 core::array::from_fn(|_| pallas::Base::random(&mut rng));
2784 let c2_x: [pallas::Base; 16] =
2785 core::array::from_fn(|_| pallas::Base::random(&mut rng));
2786 let c1_y: [pallas::Base; 16] =
2787 core::array::from_fn(|_| pallas::Base::random(&mut rng));
2788 let c2_y: [pallas::Base; 16] =
2789 core::array::from_fn(|_| pallas::Base::random(&mut rng));
2790
2791 let h1 = shares_hash(blinds, c1_x, c2_x, c1_y, c2_y);
2792 let h2 = shares_hash(blinds, c1_x, c2_x, c1_y, c2_y);
2793 assert_eq!(h1, h2);
2794
2795 // Changing any component changes the hash.
2796 let mut c1_x_alt = c1_x;
2797 c1_x_alt[2] = pallas::Base::random(&mut rng);
2798 let h3 = shares_hash(blinds, c1_x_alt, c2_x, c1_y, c2_y);
2799 assert_ne!(h1, h3);
2800
2801 // Swapping c1 and c2 also changes the hash.
2802 let h4 = shares_hash(blinds, c2_x, c1_x, c2_y, c1_y);
2803 assert_ne!(h1, h4);
2804
2805 // Different blinds produce different hash.
2806 let blinds_alt: [pallas::Base; 16] =
2807 core::array::from_fn(|_| pallas::Base::random(&mut rng));
2808 let h5 = shares_hash(blinds_alt, c1_x, c2_x, c1_y, c2_y);
2809 assert_ne!(h1, h5);
2810 }
2811
2812 /// Verifies the out-of-circuit share_commitment helper is deterministic
2813 /// and that input order matters (Poseidon(blind, c1_x, c2_x, c1_y, c2_y) ≠
2814 /// Poseidon(blind, c2_x, c1_x, c2_y, c1_y)).
2815 #[test]
2816 fn share_commitment_deterministic() {
2817 let mut rng = OsRng;
2818 let blind = pallas::Base::random(&mut rng);
2819 let c1_x = pallas::Base::random(&mut rng);
2820 let c2_x = pallas::Base::random(&mut rng);
2821 let c1_y = pallas::Base::random(&mut rng);
2822 let c2_y = pallas::Base::random(&mut rng);
2823
2824 let h1 = share_commitment(blind, c1_x, c2_x, c1_y, c2_y);
2825 let h2 = share_commitment(blind, c1_x, c2_x, c1_y, c2_y);
2826 assert_eq!(h1, h2);
2827
2828 // Swapping c1 and c2 changes the hash.
2829 let h3 = share_commitment(blind, c2_x, c1_x, c2_y, c1_y);
2830 assert_ne!(h1, h3);
2831
2832 // Different blind changes the hash.
2833 let blind_alt = pallas::Base::random(&mut rng);
2834 let h4 = share_commitment(blind_alt, c1_x, c2_x, c1_y, c2_y);
2835 assert_ne!(h1, h4);
2836 }
2837
2838 /// Minimal circuit that computes one share commitment in-circuit and constrains
2839 /// the result to the instance column. Used to verify the in-circuit hash matches
2840 /// the native share_commitment.
2841 #[derive(Clone, Default)]
2842 struct ShareCommitmentTestCircuit {
2843 blind: pallas::Base,
2844 c1_x: pallas::Base,
2845 c2_x: pallas::Base,
2846 c1_y: pallas::Base,
2847 c2_y: pallas::Base,
2848 }
2849
2850 #[derive(Clone)]
2851 struct ShareCommitmentTestConfig {
2852 primary: Column<InstanceColumn>,
2853 advices: [Column<Advice>; 5],
2854 poseidon_config: PoseidonConfig<pallas::Base, 3, 2>,
2855 }
2856
2857 impl plonk::Circuit<pallas::Base> for ShareCommitmentTestCircuit {
2858 type Config = ShareCommitmentTestConfig;
2859 type FloorPlanner = floor_planner::V1;
2860
2861 fn without_witnesses(&self) -> Self {
2862 Self::default()
2863 }
2864
2865 fn configure(meta: &mut ConstraintSystem<pallas::Base>) -> Self::Config {
2866 let primary = meta.instance_column();
2867 meta.enable_equality(primary);
2868 let advices: [Column<Advice>; 5] = core::array::from_fn(|_| meta.advice_column());
2869 for col in &advices {
2870 meta.enable_equality(*col);
2871 }
2872 let fixed: [Column<Fixed>; 6] = core::array::from_fn(|_| meta.fixed_column());
2873 let constants = meta.fixed_column();
2874 meta.enable_constant(constants);
2875 let rc_a = fixed[0..3].try_into().unwrap();
2876 let rc_b = fixed[3..6].try_into().unwrap();
2877 let poseidon_config = PoseidonChip::configure::<poseidon::P128Pow5T3>(
2878 meta,
2879 advices[1..4].try_into().unwrap(),
2880 advices[4],
2881 rc_a,
2882 rc_b,
2883 );
2884 ShareCommitmentTestConfig {
2885 primary,
2886 advices,
2887 poseidon_config,
2888 }
2889 }
2890
2891 fn synthesize(
2892 &self,
2893 config: Self::Config,
2894 mut layouter: impl Layouter<pallas::Base>,
2895 ) -> Result<(), plonk::Error> {
2896 let blind_cell = assign_free_advice(
2897 layouter.namespace(|| "blind"),
2898 config.advices[0],
2899 Value::known(self.blind),
2900 )?;
2901 let c1_x_cell = assign_free_advice(
2902 layouter.namespace(|| "c1_x"),
2903 config.advices[0],
2904 Value::known(self.c1_x),
2905 )?;
2906 let c2_x_cell = assign_free_advice(
2907 layouter.namespace(|| "c2_x"),
2908 config.advices[0],
2909 Value::known(self.c2_x),
2910 )?;
2911 let c1_y_cell = assign_free_advice(
2912 layouter.namespace(|| "c1_y"),
2913 config.advices[0],
2914 Value::known(self.c1_y),
2915 )?;
2916 let c2_y_cell = assign_free_advice(
2917 layouter.namespace(|| "c2_y"),
2918 config.advices[0],
2919 Value::known(self.c2_y),
2920 )?;
2921 let chip = PoseidonChip::construct(config.poseidon_config.clone());
2922 let result = hash_share_commitment_in_circuit(
2923 chip,
2924 layouter.namespace(|| "share_comm"),
2925 blind_cell,
2926 c1_x_cell,
2927 c2_x_cell,
2928 c1_y_cell,
2929 c2_y_cell,
2930 0,
2931 )?;
2932 layouter.constrain_instance(result.cell(), config.primary, 0)?;
2933 Ok(())
2934 }
2935 }
2936
2937 /// Verifies that the in-circuit share commitment hash matches the native
2938 /// share_commitment(blind, c1_x, c2_x, c1_y, c2_y). The test builds a minimal circuit
2939 /// that computes the hash and constrains it to the instance column, then
2940 /// runs MockProver with the native hash as the public input.
2941 #[test]
2942 fn hash_share_commitment_in_circuit_matches_native() {
2943 let mut rng = OsRng;
2944 let blind = pallas::Base::random(&mut rng);
2945 let c1_x = pallas::Base::random(&mut rng);
2946 let c2_x = pallas::Base::random(&mut rng);
2947 let c1_y = pallas::Base::random(&mut rng);
2948 let c2_y = pallas::Base::random(&mut rng);
2949
2950 let expected = share_commitment(blind, c1_x, c2_x, c1_y, c2_y);
2951 let circuit = ShareCommitmentTestCircuit {
2952 blind,
2953 c1_x,
2954 c2_x,
2955 c1_y,
2956 c2_y,
2957 };
2958 let instance = vec![vec![expected]];
2959 // K=10 (1024 rows) is enough for one Poseidon(3) region.
2960 const TEST_K: u32 = 10;
2961 let prover =
2962 MockProver::run(TEST_K, &circuit, instance).expect("MockProver::run failed");
2963 assert_eq!(prover.verify(), Ok(()));
2964 }
2965
2966 // ================================================================
2967 // Condition 11 (Encryption Integrity) tests
2968 // ================================================================
2969
2970 /// Valid El Gamal encryptions should produce a valid proof.
2971 #[test]
2972 fn encryption_integrity_valid_proof() {
2973 let (circuit, instance) = make_test_data();
2974
2975 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2976 assert_eq!(prover.verify(), Ok(()));
2977 }
2978
2979 /// A corrupted share_randomness[0] should fail condition 11:
2980 /// the computed C1[0] won't match enc_share_c1_x[0].
2981 #[test]
2982 fn encryption_integrity_wrong_randomness_fails() {
2983 let (mut circuit, instance) = make_test_data();
2984
2985 // Corrupt the randomness for share 0 — C1 will change.
2986 circuit.share_randomness[0] = Value::known(pallas::Base::from(9999u64));
2987
2988 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
2989 assert!(prover.verify().is_err());
2990 }
2991
2992 /// A wrong ea_pk in the instance should fail condition 11:
2993 /// the computed r * ea_pk won't match the ciphertexts.
2994 #[test]
2995 fn encryption_integrity_wrong_ea_pk_instance_fails() {
2996 let (circuit, mut instance) = make_test_data();
2997
2998 // Corrupt ea_pk_x in the instance — the constraint linking
2999 // the witnessed ea_pk to the public input will fail.
3000 instance.ea_pk_x = pallas::Base::from(12345u64);
3001
3002 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
3003 assert!(prover.verify().is_err());
3004 }
3005
3006 /// A corrupted share value (plaintext) should fail condition 11:
3007 /// C2_i = [v_i]*G + [r_i]*ea_pk will not match enc_share_c2_x[i].
3008 #[test]
3009 fn encryption_integrity_wrong_share_fails() {
3010 let (mut circuit, instance) = make_test_data();
3011
3012 // Corrupt share 0 — enc_share and randomness are unchanged (from
3013 // make_test_data), so the in-circuit C2_0 will not match enc_c2_x[0].
3014 circuit.shares[0] = Value::known(pallas::Base::from(9999u64));
3015
3016 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
3017 assert!(prover.verify().is_err());
3018 }
3019
3020 /// A corrupted enc_share_c2_x witness should cause verification to fail:
3021 /// condition 11 constrains ExtractP(C2_i) == enc_c2_x[i].
3022 #[test]
3023 fn encryption_integrity_wrong_enc_c2_x_fails() {
3024 let (mut circuit, instance) = make_test_data();
3025
3026 // Corrupt one C2 x-coordinate — the ECC will compute the real C2_0
3027 // from share_0 and r_0; constrain_equal will fail (or the resulting
3028 // shares_hash will not match the instance vote_commitment).
3029 circuit.enc_share_c2_x[0] = Value::known(pallas::Base::random(&mut OsRng));
3030
3031 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
3032 assert!(prover.verify().is_err());
3033 }
3034
3035 /// The out-of-circuit elgamal_encrypt helper is deterministic.
3036 #[test]
3037 fn elgamal_encrypt_deterministic() {
3038 let (_ea_sk, ea_pk_point, _ea_pk_affine) = generate_ea_keypair();
3039
3040 let v = pallas::Base::from(1000u64);
3041 let r = pallas::Base::from(42u64);
3042
3043 let (c1_a, c2_a, _, _) = elgamal_encrypt(v, r, ea_pk_point);
3044 let (c1_b, c2_b, _, _) = elgamal_encrypt(v, r, ea_pk_point);
3045 assert_eq!(c1_a, c1_b);
3046 assert_eq!(c2_a, c2_b);
3047
3048 // Different randomness → different C1.
3049 let (c1_c, _, _, _) = elgamal_encrypt(v, pallas::Base::from(99u64), ea_pk_point);
3050 assert_ne!(c1_a, c1_c);
3051 }
3052
3053 /// base_to_scalar (used by El Gamal) accepts share-sized values and
3054 /// the fixed randomness used in encrypt_shares.
3055 #[test]
3056 fn base_to_scalar_accepts_elgamal_inputs() {
3057 // Share-sized values (condition 9: [0, 2^30)) must convert.
3058 assert!(base_to_scalar(pallas::Base::zero()).is_some());
3059 assert!(base_to_scalar(pallas::Base::from(1u64)).is_some());
3060 assert!(base_to_scalar(pallas::Base::from(1_000u64)).is_some());
3061 assert!(base_to_scalar(pallas::Base::from(404u64)).is_some()); // encrypt_shares randomness
3062
3063 // encrypt_shares uses (i+1)*101 for i in 0..16 → 101, 202, ..., 1616.
3064 for r in (1u64..=16).map(|i| i * 101) {
3065 assert!(
3066 base_to_scalar(pallas::Base::from(r)).is_some(),
3067 "r = {} must convert for El Gamal",
3068 r
3069 );
3070 }
3071 }
3072
3073 // ================================================================
3074 // Condition 12 (Vote Commitment Integrity) tests
3075 // ================================================================
3076
3077 /// Valid vote commitment (full Poseidon chain) should pass.
3078 #[test]
3079 fn vote_commitment_integrity_valid_proof() {
3080 let (circuit, instance) = make_test_data();
3081
3082 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
3083 assert_eq!(prover.verify(), Ok(()));
3084 }
3085
3086 /// A wrong vote_decision in the circuit should fail condition 12:
3087 /// the derived vote_commitment won't match the instance.
3088 #[test]
3089 fn vote_commitment_wrong_decision_fails() {
3090 let (mut circuit, instance) = make_test_data();
3091
3092 // Corrupt the vote decision — the Poseidon hash will change.
3093 circuit.vote_decision = Value::known(pallas::Base::from(99u64));
3094
3095 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
3096 assert!(prover.verify().is_err());
3097 }
3098
3099 /// A wrong proposal_id in the instance should fail condition 12:
3100 /// the in-circuit proposal_id (copied from instance) will produce
3101 /// a different vote_commitment.
3102 #[test]
3103 fn vote_commitment_wrong_proposal_id_fails() {
3104 let (circuit, mut instance) = make_test_data();
3105
3106 // Corrupt the proposal_id in the instance.
3107 instance.proposal_id = pallas::Base::from(999u64);
3108
3109 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
3110 assert!(prover.verify().is_err());
3111 }
3112
3113 /// A wrong vote_commitment in the instance should fail.
3114 #[test]
3115 fn vote_commitment_wrong_instance_fails() {
3116 let (circuit, mut instance) = make_test_data();
3117
3118 // Corrupt the vote_commitment public input.
3119 instance.vote_commitment = pallas::Base::random(&mut OsRng);
3120
3121 let prover = MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]).unwrap();
3122 assert!(prover.verify().is_err());
3123 }
3124
3125 /// The out-of-circuit vote_commitment_hash helper is deterministic.
3126 #[test]
3127 fn vote_commitment_hash_deterministic() {
3128 let mut rng = OsRng;
3129
3130 let rid = pallas::Base::random(&mut rng);
3131 let sh = pallas::Base::random(&mut rng);
3132 let pid = pallas::Base::from(5u64);
3133 let dec = pallas::Base::from(1u64);
3134
3135 let h1 = vote_commitment_hash(rid, sh, pid, dec);
3136 let h2 = vote_commitment_hash(rid, sh, pid, dec);
3137 assert_eq!(h1, h2);
3138
3139 // Changing any input changes the hash.
3140 let h3 = vote_commitment_hash(rid, sh, pallas::Base::from(6u64), dec);
3141 assert_ne!(h1, h3);
3142
3143 // Changing voting_round_id changes the hash.
3144 let h4 = vote_commitment_hash(pallas::Base::from(999u64), sh, pid, dec);
3145 assert_ne!(h1, h4);
3146
3147 // DOMAIN_VC ensures separation from VAN hashes.
3148 // (Different arity prevents confusion, but domain tag adds defense-in-depth.)
3149 assert_ne!(h1, pallas::Base::zero());
3150 }
3151
3152 // ================================================================
3153 // Instance and circuit sanity
3154 // ================================================================
3155
3156 /// Instance must serialize to exactly 9 public inputs.
3157 #[test]
3158 fn instance_has_eleven_public_inputs() {
3159 let (_, instance) = make_test_data();
3160 assert_eq!(instance.to_halo2_instance().len(), 11);
3161 }
3162
3163 /// Default circuit (all witnesses unknown) must not produce a valid proof.
3164 #[test]
3165 fn default_circuit_with_valid_instance_fails() {
3166 let (_, instance) = make_test_data();
3167 let circuit = Circuit::default();
3168
3169 match MockProver::run(K, &circuit, vec![instance.to_halo2_instance()]) {
3170 Ok(prover) => assert!(prover.verify().is_err()),
3171 Err(_) => {} // Synthesis failed — acceptable.
3172 }
3173 }
3174
3175 /// Measures actual rows used by the vote-proof circuit via `CircuitCost::measure`.
3176 ///
3177 /// `CircuitCost` runs the floor planner against the circuit and tracks the
3178 /// highest row offset assigned in any column, giving the real "rows consumed"
3179 /// number rather than the theoretical 2^K capacity.
3180 ///
3181 /// Run with:
3182 /// cargo test --features vote-proof row_budget -- --nocapture --ignored
3183 #[test]
3184 #[ignore]
3185 fn row_budget() {
3186 use std::println;
3187 use halo2_proofs::dev::CircuitCost;
3188 use pasta_curves::vesta;
3189
3190 let (circuit, _) = make_test_data();
3191
3192 // CircuitCost::measure runs the floor planner and returns layout statistics.
3193 // Fields are private, so extract them from the Debug representation.
3194 let cost = CircuitCost::<vesta::Point, _>::measure(K, &circuit);
3195 let debug = alloc::format!("{cost:?}");
3196
3197 // Parse max_rows, max_advice_rows, max_fixed_rows from Debug string.
3198 let extract = |field: &str| -> usize {
3199 let prefix = alloc::format!("{field}: ");
3200 debug.split(&prefix)
3201 .nth(1)
3202 .and_then(|s| s.split([',', ' ', '}']).next())
3203 .and_then(|n| n.parse().ok())
3204 .unwrap_or(0)
3205 };
3206
3207 let max_rows = extract("max_rows");
3208 let max_advice_rows = extract("max_advice_rows");
3209 let max_fixed_rows = extract("max_fixed_rows");
3210 let total_available = 1usize << K;
3211
3212 println!("=== vote-proof circuit row budget (K={K}) ===");
3213 println!(" max_rows (floor-planner high-water mark): {max_rows}");
3214 println!(" max_advice_rows: {max_advice_rows}");
3215 println!(" max_fixed_rows: {max_fixed_rows}");
3216 println!(" 2^K (total available rows): {total_available}");
3217 println!(" headroom: {}", total_available.saturating_sub(max_rows));
3218 println!(" utilisation: {:.1}%",
3219 100.0 * max_rows as f64 / total_available as f64);
3220 println!();
3221 println!(" Full debug: {debug}");
3222
3223 // ---------------------------------------------------------------
3224 // Witness-independence check: Circuit::default() (all unknowns)
3225 // must produce exactly the same layout as the filled circuit.
3226 // If these differ, the row count depends on witness values and
3227 // the measurement above cannot be trusted as a production bound.
3228 // ---------------------------------------------------------------
3229 let cost_default = CircuitCost::<vesta::Point, _>::measure(K, &Circuit::default());
3230 let debug_default = alloc::format!("{cost_default:?}");
3231 let max_rows_default = debug_default
3232 .split("max_rows: ").nth(1)
3233 .and_then(|s| s.split([',', ' ', '}']).next())
3234 .and_then(|n| n.parse::<usize>().ok())
3235 .unwrap_or(0);
3236 if max_rows_default == max_rows {
3237 println!(" Witness-independence: PASS \
3238 (Circuit::default() max_rows={max_rows_default} == filled max_rows={max_rows})");
3239 } else {
3240 println!(" Witness-independence: FAIL \
3241 (Circuit::default() max_rows={max_rows_default} != filled max_rows={max_rows}) \
3242 — row count depends on witness values!");
3243 }
3244
3245 // ---------------------------------------------------------------
3246 // VOTE_COMM_TREE_DEPTH sanity check: confirm the circuit constant
3247 // matches the canonical value in vote_commitment_tree::TREE_DEPTH
3248 // (24 as of this writing). A mismatch would mean test data uses a
3249 // shallower tree than production.
3250 // ---------------------------------------------------------------
3251 println!(" VOTE_COMM_TREE_DEPTH (circuit constant): {VOTE_COMM_TREE_DEPTH}");
3252
3253 // ---------------------------------------------------------------
3254 // Minimum-K probe: find the smallest K at which MockProver passes.
3255 // Useful for evaluating whether K can be reduced.
3256 // ---------------------------------------------------------------
3257 for probe_k in 11u32..=K {
3258 let (c, inst) = make_test_data();
3259 match MockProver::run(probe_k, &c, vec![inst.to_halo2_instance()]) {
3260 Err(_) => {
3261 println!(" K={probe_k}: not enough rows (synthesizer rejected)");
3262 continue;
3263 }
3264 Ok(p) => match p.verify() {
3265 Ok(()) => {
3266 println!(" Minimum viable K: {probe_k} (2^{probe_k} = {} rows, {:.1}% headroom)",
3267 1usize << probe_k,
3268 100.0 * (1.0 - max_rows as f64 / (1usize << probe_k) as f64));
3269 break;
3270 }
3271 Err(_) => println!(" K={probe_k}: too small"),
3272 },
3273 }
3274 }
3275 }
3276}