Skip to main content

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