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