Skip to main content

voting_circuits/vote_proof/
builder.rs

1//! Vote proof builder (ZKP #2).
2//!
3//! Constructs a vote proof from delegation key material, a vote commitment
4//! tree witness, and vote parameters. Lives inside the orchard crate to
5//! access `pub(crate)` key internals.
6//!
7//! El Gamal encryption randomness and share blind factors are derived
8//! deterministically via a Blake2b-512 PRF keyed by the spending key
9//! and bound to the specific VAN being spent, enabling crash recovery
10//! without persisting secrets and preventing nonce reuse across VANs.
11
12use std::string::String;
13use std::vec::Vec;
14
15use ff::{Field, FromUniformBytes, PrimeField};
16use group::{Curve, GroupEncoding};
17use halo2_proofs::circuit::Value;
18use pasta_curves::{
19    arithmetic::{Coordinates, CurveAffine},
20    pallas,
21};
22
23use orchard::keys::{FullViewingKey, Scope, SpendAuthorizingKey, SpendingKey};
24
25use super::circuit::{
26    van_integrity_hash, van_nullifier_hash, vote_commitment_hash, Circuit, Instance,
27    MAX_PROPOSAL_ID, VOTE_COMM_TREE_DEPTH,
28};
29use super::prove::create_vote_proof;
30use crate::circuit::elgamal::{base_to_scalar, spend_auth_g_affine};
31use crate::shares_hash::{share_commitment, shares_hash};
32use crate::{domain_tags, ProveError};
33
34/// Ballot divisor — must match `delegation::circuit::BALLOT_DIVISOR`.
35const BALLOT_DIVISOR: u64 = 12_500_000;
36
37/// Number of shares per vote.
38const NUM_SHARES: usize = 16;
39
40/// Standard denomination values for share decomposition (ballots, descending).
41///
42/// | Ballots    | ZEC         |
43/// |------------|-------------|
44/// | 10,000,000 | 1,250,000   |
45/// | 1,000,000  | 125,000     |
46/// | 100,000    | 12,500      |
47/// | 10,000     | 1,250       |
48/// | 1,000      | 125         |
49/// | 100        | 12.5        |
50/// | 10         | 1.25        |
51/// | 1          | 0.125       |
52const DENOMINATIONS: [u64; 8] = [10_000_000, 1_000_000, 100_000, 10_000, 1_000, 100, 10, 1];
53
54type PallasAffineCoordinates = Coordinates<pallas::Affine>;
55
56/// Maximum slots used for standard denomination shares.
57///
58/// The remaining `NUM_SHARES - MAX_DENOM_SHARES` slots (7) are reserved for
59/// random-valued shares produced by [`distribute_remainder`].  This ensures
60/// every voter's share array contains a mix of standard denominations and
61/// non-standard values, preventing the EA from reconstructing exact balances
62/// by matching denomination patterns.
63const MAX_DENOM_SHARES: usize = 9;
64
65// The remainder slots must have enough room for meaningful PRF-weighted
66// spreading. This fires at compile time if someone changes the constants.
67const _: () = assert!(
68    NUM_SHARES - MAX_DENOM_SHARES >= 7,
69    "need at least 7 remainder slots for PRF-weighted distribution"
70);
71
72/// Decompose `num_ballots` into [`NUM_SHARES`] shares using a greedy
73/// denomination strategy with randomized remainder distribution.
74///
75/// 1. **Greedy fill**: place the largest standard denominations that fit,
76///    consuming up to [`MAX_DENOM_SHARES`] slots.
77/// 2. **Remainder split**: if a non-zero remainder exists, distribute it
78///    across all free slots using deterministic PRF-derived weights.
79/// 3. The caller then shuffles the result via [`deterministic_shuffle`].
80///
81/// The randomized remainder prevents a single non-standard value from
82/// fingerprinting the voter's exact balance.
83fn denomination_split(
84    num_ballots: u64,
85    sk: &SpendingKey,
86    round_id: pallas::Base,
87    proposal_id: u64,
88    van_commitment: pallas::Base,
89) -> [u64; NUM_SHARES] {
90    let mut shares = [0u64; NUM_SHARES];
91    let mut remaining = num_ballots;
92    let mut idx = 0;
93
94    // Phase 1: Greedy fill — place the largest standard denominations that
95    // fit, consuming up to MAX_DENOM_SHARES (9) slots. These "tier" values
96    // are shared across many voters, forming the per-share anonymity set.
97    for &d in &DENOMINATIONS {
98        while remaining >= d && idx < MAX_DENOM_SHARES {
99            shares[idx] = d;
100            remaining -= d;
101            idx += 1;
102        }
103    }
104
105    // Phase 2: Remainder distribution — spread any leftover across the free
106    // slots (at least 7, enforced by the const assert above) using
107    // PRF-derived weights so no single non-standard value fingerprints the
108    // exact balance.
109    if remaining > 0 {
110        distribute_remainder(
111            &mut shares[idx..],
112            remaining,
113            sk,
114            round_id,
115            proposal_id,
116            van_commitment,
117            idx as u8,
118        );
119    }
120
121    shares
122}
123
124/// Spread `remainder` across `slots` using PRF-derived weights.
125///
126/// Each slot gets `floor(remainder * weight_i / total_weight)` with any
127/// rounding residual added one-per-slot to the first slots. Every slot
128/// receives at least 1 to maximize dispersion across all available slots.
129fn distribute_remainder(
130    slots: &mut [u64],
131    remainder: u64,
132    sk: &SpendingKey,
133    round_id: pallas::Base,
134    proposal_id: u64,
135    van_commitment: pallas::Base,
136    base_index: u8,
137) {
138    let n = slots.len() as u64;
139    // The greedy phase fills at most MAX_DENOM_SHARES (9) slots, so
140    // n >= NUM_SHARES - MAX_DENOM_SHARES >= 7 (enforced by const assert).
141
142    // Edge case: if the remainder is smaller than the number of slots, we
143    // can't put at least 1 in every slot. Just give 1 ballot to as many
144    // slots as we can and leave the rest at zero.
145    // Example: remainder=3, n=7 → slots = [1, 1, 1, 0, 0, 0, 0]
146    if remainder < n {
147        for i in 0..(remainder as usize) {
148            slots[i] = 1;
149        }
150        return;
151    }
152
153    // Ensure every slot gets at least 1 ballot so all 7 slots carry part
154    // of the remainder. This maximizes dispersion — concentrating the
155    // remainder in fewer slots would make each value larger and more
156    // informative if decrypted individually.
157    // Example: remainder=300, n=7 → distributable=293
158    let distributable = remainder - n;
159
160    // Derive a PRF weight per slot. Each weight is a 32-bit pseudorandom
161    // value from BLAKE2b, unique per (voter, VAN, proposal, slot index).
162    // The `| 1` ensures no weight is zero (avoids a slot getting nothing).
163    let mut weights = Vec::with_capacity(slots.len());
164    let mut total_weight: u64 = 0;
165    for i in 0..slots.len() {
166        let hash = vote_share_prf(
167            sk,
168            domain_tags::VOTE_PRF_DOMAIN_REMAINDER,
169            round_id,
170            proposal_id,
171            van_commitment,
172            base_index.wrapping_add(i as u8),
173        );
174        let w = u32::from_le_bytes(hash[0..4].try_into().unwrap()) as u64 | 1;
175        weights.push(w);
176        total_weight += w;
177    }
178
179    // Give each slot its reserved 1 ballot plus a weighted share of the
180    // distributable portion: floor(distributable * weight_i / total_weight).
181    // Integer division truncates, so we track how much was actually assigned.
182    let mut assigned: u64 = 0;
183    for i in 0..slots.len() {
184        let share = ((distributable as u128 * weights[i] as u128) / total_weight as u128) as u64;
185        slots[i] = 1 + share;
186        assigned += share;
187    }
188
189    // The floor divisions above may leave a small leftover (at most n-1
190    // ballots). Distribute it one-per-slot to the first slots. This is
191    // deterministic — same PRF weights → same leftover → same correction.
192    let leftover = distributable - assigned;
193    for i in 0..(leftover as usize) {
194        slots[i] += 1;
195    }
196}
197
198/// Encrypted share output from the vote proof builder.
199///
200/// Contains the El Gamal ciphertext components (compressed point bytes),
201/// plaintext share value, and encryption randomness. Returned so the caller
202/// can build reveal-share payloads using the exact ciphertexts committed in the proof.
203#[derive(Debug, Clone)]
204pub struct EncryptedShareOutput {
205    /// Compressed El Gamal C1 point (32 bytes).
206    pub c1: [u8; 32],
207    /// Compressed El Gamal C2 point (32 bytes).
208    pub c2: [u8; 32],
209    /// Share index (0-15).
210    pub share_index: u32,
211    /// Plaintext share value.
212    pub plaintext_value: u64,
213    /// El Gamal randomness r (32 bytes, LE pallas::Base repr).
214    /// Deterministically derived from (sk, round_id, proposal_id, van_commitment, share_index).
215    pub randomness: [u8; 32],
216}
217
218/// Result of building a vote proof.
219#[derive(Debug)]
220pub struct VoteProofBundle {
221    /// Serialized Halo2 proof bytes.
222    pub proof: Vec<u8>,
223    /// Public inputs for the proof.
224    pub instance: Instance,
225    /// Compressed r_vpk (32 bytes) for sighash computation and signature verification.
226    pub r_vpk_bytes: [u8; 32],
227    /// Encrypted shares generated during proof construction.
228    /// These are the exact ciphertexts committed in the vote commitment hash
229    /// and must be used for reveal-share payloads.
230    pub encrypted_shares: [EncryptedShareOutput; 16],
231    /// Poseidon hash of all blinded encrypted-share commitments.
232    /// This value is exported for reveal-share helpers, but it is not a Halo2
233    /// public input. The vote proof binds it through `instance.vote_commitment`.
234    /// Intermediate value: vote_commitment = H(DOMAIN_VC, voting_round_id, shares_hash, proposal_id, vote_decision).
235    /// Needed by the helper server to verify share payloads.
236    pub shares_hash: pallas::Base,
237    /// Per-share blind factors for blinded commitments.
238    /// See `crate::shares_hash` for the authoritative five-coordinate
239    /// commitment shape.
240    /// Deterministically derived from (sk, round_id, proposal_id, van_commitment, share_index).
241    pub share_blinds: [pallas::Base; 16],
242    /// Pre-computed per-share Poseidon commitments.
243    /// Each commitment binds the blind and both coordinates of both El Gamal
244    /// ciphertext points; `crate::shares_hash` is the source of truth for the
245    /// preimage order.
246    /// Provided as private witness inputs to the ZKP #3 builder. The reveal
247    /// circuit binds them transitively through `shares_hash`, so the helper
248    /// server only needs the primary share's blind, not all 16.
249    pub share_comms: [pallas::Base; 16],
250}
251
252/// Errors that can occur during vote proof construction.
253#[derive(Debug)]
254pub enum VoteProofBuildError {
255    /// A share randomness value could not be converted to a scalar.
256    InvalidRandomness(String),
257    /// The total note value cannot be split into valid shares.
258    InvalidShares(String),
259    /// The election authority's public key is the identity point.
260    InvalidElectionPublicKey,
261    /// The randomized voting public key is the identity point.
262    InvalidRandomizedVotingPublicKey,
263    /// A derived El Gamal ciphertext point was the identity point.
264    InvalidEncryptedShare(String),
265    /// The proposal identifier is outside the supported 1-indexed range.
266    InvalidProposalId(u64),
267    /// Halo2 proof creation failed.
268    Prove(ProveError),
269}
270
271impl From<ProveError> for VoteProofBuildError {
272    fn from(error: ProveError) -> Self {
273        VoteProofBuildError::Prove(error)
274    }
275}
276
277impl core::fmt::Display for VoteProofBuildError {
278    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
279        match self {
280            VoteProofBuildError::InvalidRandomness(msg) => {
281                write!(f, "invalid randomness: {}", msg)
282            }
283            VoteProofBuildError::InvalidShares(msg) => {
284                write!(f, "invalid shares: {}", msg)
285            }
286            VoteProofBuildError::InvalidElectionPublicKey => {
287                write!(f, "invalid election public key: identity point")
288            }
289            VoteProofBuildError::InvalidRandomizedVotingPublicKey => {
290                write!(f, "invalid randomized voting public key: identity point")
291            }
292            VoteProofBuildError::InvalidEncryptedShare(msg) => {
293                write!(f, "invalid encrypted share: {}", msg)
294            }
295            VoteProofBuildError::InvalidProposalId(proposal_id) => {
296                write!(
297                    f,
298                    "proposal_id must be in [1, {}], got {}",
299                    MAX_PROPOSAL_ID - 1,
300                    proposal_id
301                )
302            }
303            VoteProofBuildError::Prove(error) => {
304                write!(f, "proof generation failed: {error}")
305            }
306        }
307    }
308}
309
310impl std::error::Error for VoteProofBuildError {
311    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
312        match self {
313            VoteProofBuildError::Prove(error) => Some(error),
314            _ => None,
315        }
316    }
317}
318
319fn pallas_coordinates(point: pallas::Affine) -> Option<PallasAffineCoordinates> {
320    point.coordinates().into()
321}
322
323fn encrypted_share_coordinates(
324    point: pallas::Affine,
325    share_index: usize,
326    component: &'static str,
327) -> Result<PallasAffineCoordinates, VoteProofBuildError> {
328    pallas_coordinates(point).ok_or_else(|| {
329        VoteProofBuildError::InvalidEncryptedShare(format!(
330            "share {} {} is identity",
331            share_index, component
332        ))
333    })
334}
335
336/// Extract the voting spending key scalar from a SpendingKey.
337///
338/// This replicates the sign-correction logic from `SpendAuthorizingKey::from`:
339/// `ask = PRF_expand(sk)`, then negate if the resulting ak has ỹ = 1.
340fn extract_vsk(sk: &SpendingKey) -> pallas::Scalar {
341    let ask_raw = SpendAuthorizingKey::derive_inner(sk);
342    let g = pallas::Point::from(spend_auth_g_affine());
343    let ak_point = (g * ask_raw).to_affine();
344    let ak_bytes = ak_point.to_bytes();
345
346    // If the sign bit of ak is 1, the real ask was negated.
347    if (ak_bytes.as_ref()[31] >> 7) == 1 {
348        -ask_raw
349    } else {
350        ask_raw
351    }
352}
353
354/// Core PRF: BLAKE2b-512 bound to the spending key with voting-specific
355/// personalization and domain-separated inputs.
356///
357/// `PRF(sk, domain, round_id, proposal_id, van_commitment, share_index)`
358///   = BLAKE2b-512("ZcashVote_Expand", sk || domain || round_id || proposal_id_le64 || van_commitment || share_index_u8)
359///
360/// The `van_commitment` field binds the derivation to a specific VAN.
361/// Without it, a user with multiple VANs (from >5 notes in Phase 1)
362/// voting on the same proposal would derive identical El Gamal nonces,
363/// enabling a classic nonce-reuse attack on the ciphertexts.
364fn vote_share_prf(
365    sk: &SpendingKey,
366    domain: u8,
367    round_id: pallas::Base,
368    proposal_id: u64,
369    van_commitment: pallas::Base,
370    share_index: u8,
371) -> [u8; 64] {
372    *blake2b_simd::Params::new()
373        .hash_length(64)
374        .personal(domain_tags::VOTE_PRF_PERSONALIZATION)
375        .to_state()
376        .update(sk.to_bytes())
377        .update(&[domain])
378        .update(&round_id.to_repr())
379        .update(&proposal_id.to_le_bytes())
380        .update(&van_commitment.to_repr())
381        .update(&[share_index])
382        .finalize()
383        .as_array()
384}
385
386/// Derive deterministic El Gamal randomness `r_i` for a share.
387///
388/// Returns a non-zero `pallas::Base` element that is also a valid `pallas::Scalar`.
389/// We reduce mod p_base first; since p_base < q_scalar on the Pallas curve,
390/// every Base element is representable as a Scalar.
391fn derive_share_randomness(
392    sk: &SpendingKey,
393    round_id: pallas::Base,
394    proposal_id: u64,
395    van_commitment: pallas::Base,
396    share_index: u8,
397) -> pallas::Base {
398    let hash = vote_share_prf(
399        sk,
400        domain_tags::VOTE_PRF_DOMAIN_ELGAMAL,
401        round_id,
402        proposal_id,
403        van_commitment,
404        share_index,
405    );
406    let r = pallas::Base::from_uniform_bytes(&hash);
407    if bool::from(r.is_zero()) {
408        // Preserve deterministic derivation while satisfying the circuit's
409        // non-zero randomness gate in the negligible exact-zero case.
410        return pallas::Base::one();
411    }
412    debug_assert!(base_to_scalar(r).is_some(), "p < q guarantees Base→Scalar");
413    r
414}
415
416/// Derive deterministic blind factor `blind_i` for a share commitment.
417fn derive_share_blind(
418    sk: &SpendingKey,
419    round_id: pallas::Base,
420    proposal_id: u64,
421    van_commitment: pallas::Base,
422    share_index: u8,
423) -> pallas::Base {
424    let hash = vote_share_prf(
425        sk,
426        domain_tags::VOTE_PRF_DOMAIN_BLIND,
427        round_id,
428        proposal_id,
429        van_commitment,
430        share_index,
431    );
432    pallas::Base::from_uniform_bytes(&hash)
433}
434
435/// Deterministic Fisher-Yates shuffle of the shares array.
436///
437/// Prevents the sorted denomination order from leaking balance information
438/// through share indices. An adversary seeing (index, decrypted_value)
439/// would otherwise learn the denomination's rank in the sorted
440/// decomposition, tightening its estimate of the voter's total balance.
441/// Shuffling makes each index equally likely to hold any denomination.
442///
443/// The permutation is derived from the same PRF used for El Gamal randomness
444/// and blind factors, with a distinct shuffle domain separator.
445/// Share index 0 is used for the PRF call (the seed depends on the VAN, round,
446/// and proposal — not on the permutation step) to produce 64 bytes of
447/// pseudorandom data, which is consumed 4 bytes at a time for modular indices.
448fn deterministic_shuffle(
449    shares: &mut [u64; NUM_SHARES],
450    sk: &SpendingKey,
451    round_id: pallas::Base,
452    proposal_id: u64,
453    van_commitment: pallas::Base,
454) {
455    // The share index is hardcoded to 0 here because the shuffle
456    // function only needs one PRF call to seed the entire Fisher
457    // Yates shuffle. It doesn't need per-share derivations.
458    let seed = vote_share_prf(
459        sk,
460        domain_tags::VOTE_PRF_DOMAIN_SHUFFLE,
461        round_id,
462        proposal_id,
463        van_commitment,
464        0,
465    );
466    for i in (1..NUM_SHARES).rev() {
467        // Each iteration consumes the next 4-byte slice of the seed as a
468        // random u32: i=15 reads seed[0..4], i=14 reads seed[4..8], …,
469        // i=1 reads seed[56..60] (15 draws × 4 bytes = 60 of the 64-byte seed).
470        let byte_offset = (NUM_SHARES - 1 - i) * 4;
471        let rand_bytes: [u8; 4] = seed[byte_offset..byte_offset + 4]
472            .try_into()
473            .expect("64-byte seed has room for 15 × 4-byte draws");
474        let j = (u32::from_le_bytes(rand_bytes) as usize) % (i + 1);
475        shares.swap(i, j);
476    }
477}
478
479/// Build a real vote proof (ZKP #2) from delegation key material.
480///
481/// This function constructs the full vote proof circuit, computes all
482/// public inputs, and generates a Halo2 proof.
483///
484/// # Arguments
485///
486/// * `sk` - The SpendingKey used during delegation (ZKP #1).
487/// * `address_index` - The diversifier index of the output recipient
488///   address used in delegation (typically 1).
489/// * `total_note_value` - Sum of delegated note values in raw zatoshi (e.g. 15_000_000).
490///   Internally converted to ballot count via floor-division by BALLOT_DIVISOR.
491/// * `van_comm_rand` - The blinding factor used for the VAN in delegation.
492/// * `voting_round_id` - The active governance round identifier (Pallas base
493///   field element). The caller must authenticate it from the round
494///   announcement.
495/// * `vote_comm_tree_path` - Merkle authentication path (24 siblings) for
496///   the VAN in the vote commitment tree.
497/// * `vote_comm_tree_position` - Leaf position of the VAN in the tree.
498/// * `anchor_height` - Caller-authenticated chain height used by the verifier
499///   or chain to source the vote commitment tree root. The circuit carries this
500///   as a public input but does not derive or constrain the height itself.
501/// * `proposal_id` - Which proposal to vote on (1-indexed, must be in [1, 15]).
502///   The builder checks only this circuit-supported range; the caller must
503///   ensure the proposal is active for `voting_round_id`.
504/// * `vote_decision` - The voter's choice.
505/// * `ea_pk` - Election authority public key (Pallas affine point). The caller
506///   must authenticate this against the active round's governance announcement;
507///   the builder and circuit only prove encryption under the supplied key.
508/// * `alpha_v` - Spend auth randomizer for the voting hotkey. The caller
509///   retains this to sign the sighash with `rsk_v = ask_v.randomize(&alpha_v)`.
510///
511/// # Caller contract
512///
513/// `alpha_v` is a secret, one-time spend-auth randomizer and MUST be drawn
514/// from a CSPRNG such as `OsRng` for each vote proof. `van_comm_rand` is the
515/// secret VAN commitment blinding factor originally used by
516/// `delegation::build_delegation_bundle`; pass the retained value unchanged.
517/// `voting_round_id`, `anchor_height`, `proposal_id`, `ea_pk`, and the vote
518/// commitment tree witness are authenticated session parameters: the builder
519/// constrains proofs to the supplied values but cannot prove they came from the
520/// intended chain state or governance announcement.
521///
522/// El Gamal encryption randomness (`r_i`) and share blind factors (`blind_i`)
523/// are derived deterministically from `sk`, `voting_round_id`, `proposal_id`,
524/// `vote_authority_note_old`, and each share's index via a Blake2b-512 PRF.
525/// Including the VAN commitment prevents nonce reuse when the same user has
526/// multiple VANs (from >5 notes in Phase 1) voting on the same proposal.
527/// This allows the client to re-derive the same secrets after a crash without
528/// persisting them.
529///
530/// **Expensive**: K=13 proof generation takes ~30-60 seconds in release mode.
531#[allow(clippy::too_many_arguments)]
532pub fn build_vote_proof_from_delegation(
533    sk: &SpendingKey,
534    address_index: u32,
535    total_note_value: u64,
536    van_comm_rand: pallas::Base,
537    voting_round_id: pallas::Base,
538    vote_comm_tree_path: [pallas::Base; VOTE_COMM_TREE_DEPTH],
539    vote_comm_tree_position: u32,
540    anchor_height: u32,
541    proposal_id: u64,
542    vote_decision: u64,
543    ea_pk: pallas::Affine,
544    alpha_v: pallas::Scalar,
545    proposal_authority_old_u64: u64,
546    single_share: bool,
547) -> Result<VoteProofBundle, VoteProofBuildError> {
548    if proposal_id == 0 || proposal_id >= MAX_PROPOSAL_ID as u64 {
549        return Err(VoteProofBuildError::InvalidProposalId(proposal_id));
550    }
551
552    let ea_pk_coords =
553        pallas_coordinates(ea_pk).ok_or(VoteProofBuildError::InvalidElectionPublicKey)?;
554    let ea_pk_x = *ea_pk_coords.x();
555    let ea_pk_y = *ea_pk_coords.y();
556    let ea_pk_point = pallas::Point::from(ea_pk);
557
558    // ---- Key derivation (matches delegation's key hierarchy) ----
559
560    let vsk = extract_vsk(sk);
561    let fvk: FullViewingKey = sk.into();
562    let vsk_nk = fvk.nk().inner();
563    let rivk_v = fvk.rivk(Scope::External).inner();
564
565    let address = fvk.address_at(address_index, Scope::External);
566    let vpk_g_d_affine = address.g_d().to_affine();
567    let vpk_pk_d_affine = address.pk_d().inner().to_affine();
568
569    let vpk_g_d_coords = pallas_coordinates(vpk_g_d_affine)
570        .expect("orchard address g_d is non-identity by construction");
571    let vpk_pk_d_coords = pallas_coordinates(vpk_pk_d_affine)
572        .expect("orchard address pk_d is non-identity by construction");
573    let vpk_g_d_x = *vpk_g_d_coords.x();
574    let vpk_pk_d_x = *vpk_pk_d_coords.x();
575
576    // ---- Fast key-chain consistency checks (instant, no circuit) ----
577    {
578        use core::iter;
579        use group::ff::PrimeFieldBits;
580        use halo2_gadgets::sinsemilla::primitives::CommitDomain;
581        use orchard::constants::{fixed_bases::COMMIT_IVK_PERSONALIZATION, L_ORCHARD_BASE};
582
583        // Check 1: [vsk] * SpendAuthG must match the ak from the FullViewingKey.
584        let ak_from_vsk = (pallas::Point::from(spend_auth_g_affine()) * vsk).to_affine();
585        let fvk_bytes = fvk.to_bytes();
586        let ak_from_fvk_bytes: [u8; 32] = fvk_bytes[0..32].try_into().unwrap();
587        let ak_from_fvk: pallas::Affine = {
588            let opt: Option<pallas::Point> = pallas::Point::from_bytes(&ak_from_fvk_bytes).into();
589            opt.expect("ak from fvk must be a valid point").to_affine()
590        };
591        assert_eq!(
592            ak_from_vsk, ak_from_fvk,
593            "extract_vsk bug: [vsk]*SpendAuthG != ak from FullViewingKey"
594        );
595
596        // Check 2: CommitIvk(ak_x, nk, rivk) must produce an ivk where [ivk]*g_d == pk_d.
597        let ak_from_vsk_coords = pallas_coordinates(ak_from_vsk)
598            .expect("valid Orchard spending keys have nonzero spend authorizing keys");
599        let ak_x = *ak_from_vsk_coords.x();
600        let domain = CommitDomain::new(COMMIT_IVK_PERSONALIZATION);
601        let ivk = domain
602            .short_commit(
603                iter::empty()
604                    .chain(ak_x.to_le_bits().iter().by_vals().take(L_ORCHARD_BASE))
605                    .chain(vsk_nk.to_le_bits().iter().by_vals().take(L_ORCHARD_BASE)),
606                &rivk_v,
607            )
608            .expect("CommitIvk must not produce bottom");
609        let ivk_scalar = base_to_scalar(ivk).expect("ivk must be convertible to scalar");
610        let pk_d_derived = (pallas::Point::from(vpk_g_d_affine) * ivk_scalar).to_affine();
611        assert_eq!(
612            pk_d_derived, vpk_pk_d_affine,
613            "CommitIvk chain mismatch: [ivk]*g_d != pk_d from address"
614        );
615    }
616
617    // ---- Proposal authority ----
618
619    let proposal_authority_old = pallas::Base::from(proposal_authority_old_u64);
620    let one_shifted = pallas::Base::from(1u64 << proposal_id);
621    let proposal_authority_new = proposal_authority_old - one_shifted;
622
623    // ---- Ballot scaling (must match ZKP #1's BALLOT_DIVISOR) ----
624
625    let num_ballots = total_note_value / BALLOT_DIVISOR;
626    let num_ballots_base = pallas::Base::from(num_ballots);
627
628    // ---- VAN integrity hashes ----
629    // The VAN commitment hashes num_ballots (not raw zatoshi), matching
630    // the delegation circuit (ZKP #1 condition 7).
631
632    let vote_authority_note_old = van_integrity_hash(
633        vpk_g_d_x,
634        vpk_pk_d_x,
635        num_ballots_base,
636        voting_round_id,
637        proposal_authority_old,
638        van_comm_rand,
639    );
640
641    let van_nullifier = van_nullifier_hash(vsk_nk, voting_round_id, vote_authority_note_old);
642
643    let vote_authority_note_new = van_integrity_hash(
644        vpk_g_d_x,
645        vpk_pk_d_x,
646        num_ballots_base,
647        voting_round_id,
648        proposal_authority_new,
649        van_comm_rand,
650    );
651
652    // ---- Shares (denomination-based split of num_ballots into 16 parts) ----
653    // Each share must be in [0, 2^30) for the range check.
654    // Shares sum to num_ballots (ballot count), not raw zatoshi.
655
656    let shares_u64: [u64; 16] = if single_share {
657        // Last-moment mode: put entire weight in share[0], rest are zero.
658        // Only one share is revealed to the helper, minimizing latency
659        // when voting near the deadline.
660        let mut s = [0u64; 16];
661        s[0] = num_ballots;
662        s
663    } else {
664        let mut s = denomination_split(
665            num_ballots,
666            sk,
667            voting_round_id,
668            proposal_id,
669            vote_authority_note_old,
670        );
671        deterministic_shuffle(
672            &mut s,
673            sk,
674            voting_round_id,
675            proposal_id,
676            vote_authority_note_old,
677        );
678        s
679    };
680
681    // Verify all shares are in range
682    for (i, &s) in shares_u64.iter().enumerate() {
683        if s >= (1u64 << 30) {
684            return Err(VoteProofBuildError::InvalidShares(format!(
685                "share {} = {} exceeds 2^30",
686                i, s
687            )));
688        }
689    }
690
691    let shares_base: [pallas::Base; 16] =
692        core::array::from_fn(|i| pallas::Base::from(shares_u64[i]));
693
694    // ---- El Gamal encryption of shares ----
695    //
696    // Encrypts each share and captures both point coordinates for circuit
697    // constraints plus the full compressed point bytes for reveal-share payloads.
698
699    let g = pallas::Point::from(spend_auth_g_affine());
700    let mut enc_c1_x = [pallas::Base::zero(); 16];
701    let mut enc_c2_x = [pallas::Base::zero(); 16];
702    let mut enc_c1_y = [pallas::Base::zero(); 16];
703    let mut enc_c2_y = [pallas::Base::zero(); 16];
704    let mut share_randomness = [pallas::Base::zero(); 16];
705    let mut enc_share_outputs: [EncryptedShareOutput; 16] =
706        core::array::from_fn(|i| EncryptedShareOutput {
707            c1: [0u8; 32],
708            c2: [0u8; 32],
709            share_index: i as u32,
710            plaintext_value: shares_u64[i],
711            randomness: [0u8; 32],
712        });
713
714    for i in 0..16 {
715        let r = derive_share_randomness(
716            sk,
717            voting_round_id,
718            proposal_id,
719            vote_authority_note_old,
720            i as u8,
721        );
722        share_randomness[i] = r;
723        let r_scalar =
724            base_to_scalar(r).expect("derive_share_randomness guarantees nonzero scalar-range");
725        let v_scalar = base_to_scalar(shares_base[i]).expect("share value in range");
726
727        let c1_point = (g * r_scalar).to_affine();
728        let c2_point = (g * v_scalar + ea_pk_point * r_scalar).to_affine();
729
730        let c1_coords = encrypted_share_coordinates(c1_point, i, "c1")?;
731        let c2_coords = encrypted_share_coordinates(c2_point, i, "c2")?;
732
733        enc_c1_x[i] = *c1_coords.x();
734        enc_c2_x[i] = *c2_coords.x();
735        enc_c1_y[i] = *c1_coords.y();
736        enc_c2_y[i] = *c2_coords.y();
737
738        enc_share_outputs[i].c1 = c1_point.to_bytes();
739        enc_share_outputs[i].c2 = c2_point.to_bytes();
740        enc_share_outputs[i].randomness = r.to_repr();
741    }
742
743    let share_blinds: [pallas::Base; 16] = core::array::from_fn(|i| {
744        derive_share_blind(
745            sk,
746            voting_round_id,
747            proposal_id,
748            vote_authority_note_old,
749            i as u8,
750        )
751    });
752    let share_comms: [pallas::Base; 16] = core::array::from_fn(|i| {
753        share_commitment(
754            share_blinds[i],
755            enc_c1_x[i],
756            enc_c2_x[i],
757            enc_c1_y[i],
758            enc_c2_y[i],
759        )
760    });
761    let shares_hash_val = shares_hash(share_blinds, enc_c1_x, enc_c2_x, enc_c1_y, enc_c2_y);
762
763    // ---- Condition 4: r_vpk = ak + [alpha_v] * G ----
764    // alpha_v is now provided by the caller so they can sign with rsk_v.
765    let ak_point = pallas::Point::from(spend_auth_g_affine()) * vsk;
766    let r_vpk = (ak_point + pallas::Point::from(spend_auth_g_affine()) * alpha_v).to_affine();
767    let r_vpk_coords =
768        pallas_coordinates(r_vpk).ok_or(VoteProofBuildError::InvalidRandomizedVotingPublicKey)?;
769    let r_vpk_x = *r_vpk_coords.x();
770    let r_vpk_y = *r_vpk_coords.y();
771    let r_vpk_bytes: [u8; 32] = r_vpk.to_bytes();
772
773    // ---- Vote commitment ----
774
775    let proposal_id_base = pallas::Base::from(proposal_id);
776    let vote_decision_base = pallas::Base::from(vote_decision);
777    let vote_commitment = vote_commitment_hash(
778        voting_round_id,
779        shares_hash_val,
780        proposal_id_base,
781        vote_decision_base,
782    );
783
784    // ---- Vote commitment tree root (from auth path) ----
785    // Recompute the root from the leaf + auth path to set as public input.
786
787    let vote_comm_tree_root = {
788        use super::circuit::poseidon_hash_2;
789
790        let mut current = vote_authority_note_old;
791        for level in 0..VOTE_COMM_TREE_DEPTH {
792            let sibling = vote_comm_tree_path[level];
793            if vote_comm_tree_position & (1 << level) == 0 {
794                current = poseidon_hash_2(current, sibling);
795            } else {
796                current = poseidon_hash_2(sibling, current);
797            }
798        }
799        current
800    };
801
802    // ---- Build circuit ----
803
804    let mut circuit = Circuit::with_van_witnesses(
805        Value::known(vote_comm_tree_path),
806        Value::known(vote_comm_tree_position),
807        Value::known(vpk_g_d_affine),
808        Value::known(vpk_pk_d_affine),
809        Value::known(num_ballots_base),
810        Value::known(proposal_authority_old),
811        Value::known(van_comm_rand),
812        Value::known(vote_authority_note_old),
813        Value::known(vsk),
814        Value::known(rivk_v),
815        Value::known(vsk_nk),
816        Value::known(alpha_v),
817    );
818    circuit.one_shifted = Value::known(one_shifted);
819    circuit.shares = shares_base.map(Value::known);
820    circuit.enc_share_c1_x = enc_c1_x.map(Value::known);
821    circuit.enc_share_c2_x = enc_c2_x.map(Value::known);
822    circuit.enc_share_c1_y = enc_c1_y.map(Value::known);
823    circuit.enc_share_c2_y = enc_c2_y.map(Value::known);
824    circuit.share_blinds = share_blinds.map(Value::known);
825    circuit.share_randomness = share_randomness.map(Value::known);
826    circuit.ea_pk = Value::known(ea_pk);
827    circuit.vote_decision = Value::known(vote_decision_base);
828
829    // ---- Build instance (public inputs) ----
830
831    let anchor_height_base = pallas::Base::from(u64::from(anchor_height));
832    let instance = Instance::from_parts(
833        van_nullifier,
834        r_vpk_x,
835        r_vpk_y,
836        vote_authority_note_new,
837        vote_commitment,
838        vote_comm_tree_root,
839        anchor_height_base,
840        proposal_id_base,
841        voting_round_id,
842        ea_pk_x,
843        ea_pk_y,
844    );
845
846    // ---- Generate proof ----
847
848    let proof = create_vote_proof(circuit, &instance)?;
849
850    Ok(VoteProofBundle {
851        proof,
852        instance,
853        r_vpk_bytes,
854        encrypted_shares: enc_share_outputs,
855        shares_hash: shares_hash_val,
856        share_blinds,
857        share_comms,
858    })
859}
860
861#[cfg(test)]
862mod tests {
863    use super::*;
864    use ff::Field;
865    use group::Group;
866
867    fn test_sk() -> SpendingKey {
868        SpendingKey::from_bytes([0x42; 32]).expect("valid spending key")
869    }
870
871    fn test_round_id() -> pallas::Base {
872        pallas::Base::from(0xCAFE_u64)
873    }
874
875    fn test_van() -> pallas::Base {
876        pallas::Base::from(0xDEAD_u64)
877    }
878
879    #[test]
880    fn vote_share_prf_has_frozen_test_vector() {
881        let hash = vote_share_prf(
882            &test_sk(),
883            crate::domain_tags::VOTE_PRF_DOMAIN_ELGAMAL,
884            test_round_id(),
885            1,
886            test_van(),
887            0,
888        );
889
890        assert_eq!(
891            hash,
892            [
893                0x62, 0x03, 0x29, 0x9b, 0x2c, 0x58, 0x4b, 0xa6, 0x37, 0x4d, 0xbe, 0xd6, 0x45, 0x71,
894                0x6f, 0x03, 0x31, 0x56, 0x95, 0x6f, 0xf1, 0x88, 0x8e, 0x75, 0x41, 0x43, 0xb1, 0xf5,
895                0x54, 0xea, 0xb5, 0xb0, 0x6b, 0xdf, 0x7d, 0xca, 0xd4, 0x5a, 0xc2, 0xf4, 0xb9, 0x6a,
896                0xe4, 0x5b, 0xb9, 0x98, 0xd0, 0x5b, 0x4a, 0x8f, 0x12, 0x49, 0x52, 0xb3, 0x0b, 0x19,
897                0xc1, 0xaf, 0x89, 0x35, 0x8a, 0x96, 0xe0, 0x2c,
898            ]
899        );
900    }
901
902    #[test]
903    fn build_vote_proof_rejects_invalid_proposal_id() {
904        let sk = test_sk();
905
906        for proposal_id in [0, MAX_PROPOSAL_ID as u64, 64] {
907            let err = build_vote_proof_from_delegation(
908                &sk,
909                1,
910                BALLOT_DIVISOR,
911                test_van(),
912                test_round_id(),
913                [pallas::Base::from(0u64); VOTE_COMM_TREE_DEPTH],
914                0,
915                123,
916                proposal_id,
917                1,
918                pallas::Point::identity().to_affine(),
919                pallas::Scalar::from(7u64),
920                65535,
921                true,
922            )
923            .expect_err("invalid proposal_id should be rejected before proof generation");
924
925            assert!(matches!(
926                err,
927                VoteProofBuildError::InvalidProposalId(rejected) if rejected == proposal_id
928            ));
929        }
930    }
931
932    #[test]
933    fn build_vote_proof_rejects_identity_ea_pk() {
934        let sk = test_sk();
935        let err = build_vote_proof_from_delegation(
936            &sk,
937            1,
938            BALLOT_DIVISOR,
939            test_van(),
940            test_round_id(),
941            [pallas::Base::from(0u64); VOTE_COMM_TREE_DEPTH],
942            0,
943            123,
944            1,
945            1,
946            pallas::Point::identity().to_affine(),
947            pallas::Scalar::from(7u64),
948            65535,
949            true,
950        )
951        .expect_err("identity ea_pk should be rejected before proof generation");
952
953        assert!(matches!(err, VoteProofBuildError::InvalidElectionPublicKey));
954    }
955
956    #[test]
957    fn encrypted_share_coordinates_rejects_identity_c1_point() {
958        let err = encrypted_share_coordinates(pallas::Point::identity().to_affine(), 7, "c1")
959            .expect_err("identity c1 point should be rejected");
960
961        assert!(matches!(
962            err,
963            VoteProofBuildError::InvalidEncryptedShare(msg)
964                if msg == "share 7 c1 is identity"
965        ));
966    }
967
968    #[test]
969    fn build_vote_proof_rejects_identity_c2_point() {
970        let sk = test_sk();
971        let voting_round_id = test_round_id();
972        let proposal_id = 1;
973        let proposal_authority_old_u64 = 65535;
974        let van_comm_rand = test_van();
975        let num_ballots_base = pallas::Base::from(1u64);
976
977        let fvk: FullViewingKey = (&sk).into();
978        let address = fvk.address_at(1u32, Scope::External);
979        let vpk_g_d_affine = address.g_d().to_affine();
980        let vpk_pk_d_affine = address.pk_d().inner().to_affine();
981        let vpk_g_d_x = *vpk_g_d_affine.coordinates().unwrap().x();
982        let vpk_pk_d_x = *vpk_pk_d_affine.coordinates().unwrap().x();
983
984        let vote_authority_note_old = van_integrity_hash(
985            vpk_g_d_x,
986            vpk_pk_d_x,
987            num_ballots_base,
988            voting_round_id,
989            pallas::Base::from(proposal_authority_old_u64),
990            van_comm_rand,
991        );
992        let r = derive_share_randomness(
993            &sk,
994            voting_round_id,
995            proposal_id,
996            vote_authority_note_old,
997            0,
998        );
999        let r_scalar = base_to_scalar(r).expect("test randomness should be scalar-range");
1000        let r_inv: Option<pallas::Scalar> = r_scalar.invert().into();
1001        let ea_pk_scalar =
1002            -pallas::Scalar::from(1u64) * r_inv.expect("test randomness should be non-zero");
1003        let ea_pk = (pallas::Point::from(spend_auth_g_affine()) * ea_pk_scalar).to_affine();
1004
1005        let err = build_vote_proof_from_delegation(
1006            &sk,
1007            1,
1008            BALLOT_DIVISOR,
1009            van_comm_rand,
1010            voting_round_id,
1011            [pallas::Base::from(0u64); VOTE_COMM_TREE_DEPTH],
1012            0,
1013            123,
1014            proposal_id,
1015            1,
1016            ea_pk,
1017            pallas::Scalar::from(7u64),
1018            proposal_authority_old_u64,
1019            true,
1020        )
1021        .expect_err("crafted ea_pk should make share 0 c2 the identity");
1022
1023        assert!(matches!(
1024            err,
1025            VoteProofBuildError::InvalidEncryptedShare(msg)
1026                if msg == "share 0 c2 is identity"
1027        ));
1028    }
1029
1030    #[test]
1031    fn build_vote_proof_rejects_identity_r_vpk() {
1032        let sk = test_sk();
1033        let ea_pk =
1034            (pallas::Point::from(spend_auth_g_affine()) * pallas::Scalar::from(42u64)).to_affine();
1035        let err = build_vote_proof_from_delegation(
1036            &sk,
1037            1,
1038            BALLOT_DIVISOR,
1039            test_van(),
1040            test_round_id(),
1041            [pallas::Base::from(0u64); VOTE_COMM_TREE_DEPTH],
1042            0,
1043            123,
1044            1,
1045            1,
1046            ea_pk,
1047            -extract_vsk(&sk),
1048            65535,
1049            true,
1050        )
1051        .expect_err("alpha_v = -vsk should make r_vpk the identity");
1052
1053        assert!(matches!(
1054            err,
1055            VoteProofBuildError::InvalidRandomizedVotingPublicKey
1056        ));
1057    }
1058
1059    #[test]
1060    fn derive_share_randomness_is_deterministic() {
1061        let sk = test_sk();
1062        let round_id = test_round_id();
1063        let van = test_van();
1064        let a = derive_share_randomness(&sk, round_id, 1, van, 0);
1065        let b = derive_share_randomness(&sk, round_id, 1, van, 0);
1066        assert_eq!(a, b);
1067    }
1068
1069    #[test]
1070    fn derive_share_blind_is_deterministic() {
1071        let sk = test_sk();
1072        let round_id = test_round_id();
1073        let van = test_van();
1074        let a = derive_share_blind(&sk, round_id, 1, van, 0);
1075        let b = derive_share_blind(&sk, round_id, 1, van, 0);
1076        assert_eq!(a, b);
1077    }
1078
1079    #[test]
1080    fn derive_share_randomness_is_nonzero_valid_scalar() {
1081        let sk = test_sk();
1082        let round_id = test_round_id();
1083        let van = test_van();
1084        for i in 0..16u8 {
1085            let r = derive_share_randomness(&sk, round_id, 1, van, i);
1086            assert!(
1087                bool::from(!r.is_zero()),
1088                "r_{} must be non-zero for the circuit hardening gate",
1089                i
1090            );
1091            assert!(
1092                base_to_scalar(r).is_some(),
1093                "r_{} must be convertible to scalar",
1094                i
1095            );
1096        }
1097    }
1098
1099    #[test]
1100    fn different_share_index_gives_different_values() {
1101        let sk = test_sk();
1102        let round_id = test_round_id();
1103        let van = test_van();
1104        let r0 = derive_share_randomness(&sk, round_id, 1, van, 0);
1105        let r1 = derive_share_randomness(&sk, round_id, 1, van, 1);
1106        assert_ne!(r0, r1);
1107
1108        let b0 = derive_share_blind(&sk, round_id, 1, van, 0);
1109        let b1 = derive_share_blind(&sk, round_id, 1, van, 1);
1110        assert_ne!(b0, b1);
1111    }
1112
1113    #[test]
1114    fn different_proposal_id_gives_different_values() {
1115        let sk = test_sk();
1116        let round_id = test_round_id();
1117        let van = test_van();
1118        let r_p1 = derive_share_randomness(&sk, round_id, 1, van, 0);
1119        let r_p2 = derive_share_randomness(&sk, round_id, 2, van, 0);
1120        assert_ne!(r_p1, r_p2);
1121    }
1122
1123    #[test]
1124    fn different_round_id_gives_different_values() {
1125        let sk = test_sk();
1126        let van = test_van();
1127        let r_a = derive_share_randomness(&sk, pallas::Base::from(1u64), 1, van, 0);
1128        let r_b = derive_share_randomness(&sk, pallas::Base::from(2u64), 1, van, 0);
1129        assert_ne!(r_a, r_b);
1130    }
1131
1132    #[test]
1133    fn randomness_and_blind_differ_for_same_inputs() {
1134        let sk = test_sk();
1135        let round_id = test_round_id();
1136        let van = test_van();
1137        let r = derive_share_randomness(&sk, round_id, 1, van, 0);
1138        let b = derive_share_blind(&sk, round_id, 1, van, 0);
1139        assert_ne!(r, b, "domain separation must prevent r == blind");
1140    }
1141
1142    #[test]
1143    fn all_16_shares_are_distinct() {
1144        let sk = test_sk();
1145        let round_id = test_round_id();
1146        let van = test_van();
1147        let randoms: Vec<_> = (0..16u8)
1148            .map(|i| derive_share_randomness(&sk, round_id, 1, van, i))
1149            .collect();
1150        let blinds: Vec<_> = (0..16u8)
1151            .map(|i| derive_share_blind(&sk, round_id, 1, van, i))
1152            .collect();
1153        for i in 0..16 {
1154            for j in (i + 1)..16 {
1155                assert_ne!(randoms[i], randoms[j], "r_{} == r_{}", i, j);
1156                assert_ne!(blinds[i], blinds[j], "blind_{} == blind_{}", i, j);
1157            }
1158        }
1159    }
1160
1161    #[test]
1162    fn different_van_commitment_gives_different_values() {
1163        let sk = test_sk();
1164        let round_id = test_round_id();
1165        let van_a = pallas::Base::from(0xAAAA_u64);
1166        let van_b = pallas::Base::from(0xBBBB_u64);
1167        for i in 0..16u8 {
1168            let r_a = derive_share_randomness(&sk, round_id, 1, van_a, i);
1169            let r_b = derive_share_randomness(&sk, round_id, 1, van_b, i);
1170            assert_ne!(r_a, r_b, "r_{} must differ across VANs", i);
1171
1172            let b_a = derive_share_blind(&sk, round_id, 1, van_a, i);
1173            let b_b = derive_share_blind(&sk, round_id, 1, van_b, i);
1174            assert_ne!(b_a, b_b, "blind_{} must differ across VANs", i);
1175        }
1176    }
1177
1178    // ---- denomination_split tests ----
1179    //
1180    // Visual key:
1181    //   D = denomination (standard value, blends across voters)
1182    //   R = random (PRF-derived, prevents exact balance fingerprint)
1183    //   0 = zero (encrypted with fresh randomness, indistinguishable from non-zero)
1184    //
1185    // Layout: [0..8] = greedy denom slots | [9..15] = remainder / random slots
1186    // After shuffle, positions are randomized — these show the pre-shuffle array.
1187
1188    /// Helper: print shares array for visual inspection during --nocapture runs.
1189    fn show(label: &str, shares: &[u64; 16]) {
1190        let parts: Vec<String> = shares
1191            .iter()
1192            .map(|&v| {
1193                if v == 0 {
1194                    "0".into()
1195                } else if v >= 1_000_000 {
1196                    format!("{}M", v / 1_000_000)
1197                } else if v >= 1_000 {
1198                    format!("{}K", v / 1_000)
1199                } else {
1200                    format!("{}", v)
1201                }
1202            })
1203            .collect();
1204        std::eprintln!("  {}: [{}]", label, parts.join(", "));
1205    }
1206
1207    #[test]
1208    fn denom_split_zero_ballots() {
1209        // 0 ballots — all slots empty
1210        // [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
1211        let sk = test_sk();
1212        let rid = test_round_id();
1213        let van = test_van();
1214        let shares = denomination_split(0, &sk, rid, 1, van);
1215        show("0 ballots", &shares);
1216        assert_eq!(shares, [0; 16]);
1217    }
1218
1219    #[test]
1220    fn denom_split_single_ballot() {
1221        // 1 ballot (0.125 ZEC) — smallest denomination
1222        // [D:1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
1223        let sk = test_sk();
1224        let rid = test_round_id();
1225        let van = test_van();
1226        let shares = denomination_split(1, &sk, rid, 1, van);
1227        show("1 ballot (0.125 ZEC)", &shares);
1228        assert_eq!(shares[0], 1);
1229        for i in 1..16 {
1230            assert_eq!(shares[i], 0);
1231        }
1232    }
1233
1234    #[test]
1235    fn denom_split_sub_zec() {
1236        // 4 ballots (0.5 ZEC)
1237        // [D:1, D:1, D:1, D:1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
1238        let sk = test_sk();
1239        let rid = test_round_id();
1240        let van = test_van();
1241        let shares = denomination_split(4, &sk, rid, 1, van);
1242        show("4 ballots (0.5 ZEC)", &shares);
1243        assert_eq!(shares[0..4], [1; 4]);
1244        for i in 4..16 {
1245            assert_eq!(shares[i], 0);
1246        }
1247    }
1248
1249    #[test]
1250    fn denom_split_one_zec() {
1251        // 8 ballots (1 ZEC)
1252        // [D:1, D:1, D:1, D:1, D:1, D:1, D:1, D:1, 0, 0, 0, 0, 0, 0, 0, 0]
1253        let sk = test_sk();
1254        let rid = test_round_id();
1255        let van = test_van();
1256        let shares = denomination_split(8, &sk, rid, 1, van);
1257        show("8 ballots (1 ZEC)", &shares);
1258        assert_eq!(shares[0..8], [1; 8]);
1259        for i in 8..16 {
1260            assert_eq!(shares[i], 0);
1261        }
1262    }
1263
1264    #[test]
1265    fn denom_split_small_balance() {
1266        // 50 ballots (6.25 ZEC) — 5 denom slots, all standard
1267        // [D:10, D:10, D:10, D:10, D:10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
1268        let sk = test_sk();
1269        let rid = test_round_id();
1270        let van = test_van();
1271        let shares = denomination_split(50, &sk, rid, 1, van);
1272        show("50 ballots (6.25 ZEC)", &shares);
1273        assert_eq!(shares[0..5], [10; 5]);
1274        for i in 5..16 {
1275            assert_eq!(shares[i], 0);
1276        }
1277    }
1278
1279    #[test]
1280    fn denom_split_all_denoms_exact() {
1281        // 11,111 ballots (1,388.9 ZEC) — one of each denom, no remainder
1282        // [D:10K, D:1K, D:100, D:10, D:1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
1283        let sk = test_sk();
1284        let rid = test_round_id();
1285        let van = test_van();
1286        let shares = denomination_split(11_111, &sk, rid, 1, van);
1287        show("11,111 ballots (1,388.9 ZEC)", &shares);
1288        assert_eq!(shares[0], 10_000);
1289        assert_eq!(shares[1], 1_000);
1290        assert_eq!(shares[2], 100);
1291        assert_eq!(shares[3], 10);
1292        assert_eq!(shares[4], 1);
1293        for i in 5..16 {
1294            assert_eq!(shares[i], 0);
1295        }
1296    }
1297
1298    #[test]
1299    fn denom_split_medium_holder_with_remainder() {
1300        // 4,800 ballots (600 ZEC) — greedy fills 9 (4×1K + 5×100 = 4,500), remainder 300
1301        // [D:1K, D:1K, D:1K, D:1K, D:100, D:100, D:100, D:100, D:100, R, R, R, R, R, R, R]
1302        //  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^  ^^^^^^^^^^^^^^^^^^^^
1303        //  9 denomination slots (4,500)                                  7 random slots (300)
1304        let sk = test_sk();
1305        let rid = test_round_id();
1306        let van = test_van();
1307        let shares = denomination_split(4_800, &sk, rid, 1, van);
1308        show("4,800 ballots (600 ZEC)", &shares);
1309        assert_eq!(shares[0..4], [1_000; 4]);
1310        assert_eq!(shares[4..9], [100; 5]);
1311        let remainder_sum: u64 = shares[9..16].iter().sum();
1312        assert_eq!(remainder_sum, 300);
1313        for i in 9..16 {
1314            assert!(shares[i] > 0, "remainder slot {} should be non-zero", i);
1315        }
1316        assert_eq!(shares.iter().sum::<u64>(), 4_800);
1317    }
1318
1319    #[test]
1320    fn denom_split_high_hamming_weight() {
1321        // 999 ballots (124.875 ZEC) — greedy fills 9 (9×100 = 900), remainder 99
1322        // [D:100, D:100, D:100, D:100, D:100, D:100, D:100, D:100, D:100, R, R, R, R, R, R, R]
1323        //  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^   ^^^^^^^^^^^^^^^^^^^^
1324        //  9 denomination slots (900)                                       7 random slots (99)
1325        let sk = test_sk();
1326        let rid = test_round_id();
1327        let van = test_van();
1328        let shares = denomination_split(999, &sk, rid, 1, van);
1329        show("999 ballots (124.875 ZEC)", &shares);
1330        assert_eq!(shares[0..9], [100; 9]);
1331        let remainder_sum: u64 = shares[9..16].iter().sum();
1332        assert_eq!(remainder_sum, 99);
1333        for i in 9..16 {
1334            assert!(shares[i] > 0, "remainder slot {} should be non-zero", i);
1335        }
1336    }
1337
1338    #[test]
1339    fn denom_split_exact_denomination_match() {
1340        // 3M ballots (375 ZEC) — 3 denom slots, no remainder
1341        // [D:1M, D:1M, D:1M, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
1342        let sk = test_sk();
1343        let rid = test_round_id();
1344        let van = test_van();
1345        let shares = denomination_split(3_000_000, &sk, rid, 1, van);
1346        show("3M ballots (375 ZEC)", &shares);
1347        assert_eq!(shares[0..3], [1_000_000; 3]);
1348        for i in 3..16 {
1349            assert_eq!(shares[i], 0);
1350        }
1351    }
1352
1353    #[test]
1354    fn denom_split_8m_ballots() {
1355        // 8M ballots (1M ZEC) — 8 denom slots, no remainder
1356        // [D:1M, D:1M, D:1M, D:1M, D:1M, D:1M, D:1M, D:1M, 0, 0, 0, 0, 0, 0, 0, 0]
1357        let sk = test_sk();
1358        let rid = test_round_id();
1359        let van = test_van();
1360        let shares = denomination_split(8_000_000, &sk, rid, 1, van);
1361        show("8M ballots (1M ZEC)", &shares);
1362        assert_eq!(shares[0..8], [1_000_000; 8]);
1363        for i in 8..16 {
1364            assert_eq!(shares[i], 0);
1365        }
1366    }
1367
1368    #[test]
1369    fn denom_split_fills_all_9_denom_slots() {
1370        // 90M ballots (11.25M ZEC) — all 9 denom slots filled, no remainder
1371        // [D:10M, D:10M, D:10M, D:10M, D:10M, D:10M, D:10M, D:10M, D:10M, 0, 0, 0, 0, 0, 0, 0]
1372        let sk = test_sk();
1373        let rid = test_round_id();
1374        let van = test_van();
1375        let shares = denomination_split(90_000_000, &sk, rid, 1, van);
1376        show("90M ballots (11.25M ZEC)", &shares);
1377        assert_eq!(shares[0..9], [10_000_000; 9]);
1378        for i in 9..16 {
1379            assert_eq!(shares[i], 0);
1380        }
1381    }
1382
1383    #[test]
1384    fn denom_split_overflow_into_remainder() {
1385        // 100M ballots (12.5M ZEC) — 9 denom slots full (9×10M), remainder 10M in 7 random slots
1386        // [D:10M, D:10M, D:10M, D:10M, D:10M, D:10M, D:10M, D:10M, D:10M, R, R, R, R, R, R, R]
1387        //  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^    ^^^^^^^^^^^^^^^^^^^^
1388        //  9 denomination slots (90M)                                        7 random slots (10M)
1389        let sk = test_sk();
1390        let rid = test_round_id();
1391        let van = test_van();
1392        let shares = denomination_split(100_000_000, &sk, rid, 1, van);
1393        show("100M ballots (12.5M ZEC)", &shares);
1394        assert_eq!(shares[0..9], [10_000_000; 9]);
1395        let remainder_sum: u64 = shares[9..16].iter().sum();
1396        assert_eq!(remainder_sum, 10_000_000);
1397        for i in 9..16 {
1398            assert!(shares[i] > 0, "remainder slot {} should be non-zero", i);
1399        }
1400    }
1401
1402    #[test]
1403    fn denom_split_mixed_with_remainder() {
1404        // 1,234,567 ballots (154,320.9 ZEC) — 9 denom slots, remainder distributed
1405        // [D:1M, D:100K, D:100K, D:10K, D:10K, D:10K, D:1K, D:1K, D:1K, R, R, R, R, R, R, R]
1406        //  greedy: 1M + 200K + 30K + 3K = 1,233,000                      remainder: 1,567
1407        let sk = test_sk();
1408        let rid = test_round_id();
1409        let van = test_van();
1410        let shares = denomination_split(1_234_567, &sk, rid, 1, van);
1411        show("1,234,567 ballots (154K ZEC)", &shares);
1412        assert_eq!(shares[0], 1_000_000);
1413        assert_eq!(shares[1..3], [100_000; 2]);
1414        assert_eq!(shares[3..6], [10_000; 3]);
1415        assert_eq!(shares[6..9], [1_000; 3]);
1416        let remainder_sum: u64 = shares[9..16].iter().sum();
1417        assert_eq!(remainder_sum, 1_567);
1418        assert_eq!(shares.iter().sum::<u64>(), 1_234_567);
1419    }
1420
1421    #[test]
1422    fn denom_split_small_remainder_fewer_than_free_slots() {
1423        // 10,000,003 ballots — 1 denom slot (10M), remainder 3 across 7 free slots
1424        // [D:10M, R:1, R:1, R:1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
1425        //  remainder 3 < 7 free slots, so only 3 of 7 get a value
1426        let sk = test_sk();
1427        let rid = test_round_id();
1428        let van = test_van();
1429        let shares = denomination_split(10_000_003, &sk, rid, 1, van);
1430        show("10,000,003 ballots", &shares);
1431        assert_eq!(shares[0], 10_000_000);
1432        let remainder_sum: u64 = shares[1..16].iter().sum();
1433        assert_eq!(remainder_sum, 3);
1434        assert_eq!(shares.iter().sum::<u64>(), 10_000_003);
1435    }
1436
1437    // ---- invariant tests ----
1438
1439    #[test]
1440    fn denom_split_sum_invariant() {
1441        let sk = test_sk();
1442        let rid = test_round_id();
1443        let van = test_van();
1444        let test_values: [u64; 14] = [
1445            0,
1446            1,
1447            50,
1448            99,
1449            100,
1450            999,
1451            1_000,
1452            10_000,
1453            100_000,
1454            1_000_000,
1455            8_234_567,
1456            20_000_000,
1457            80_000_000,
1458            168_000_000,
1459        ];
1460        for &v in &test_values {
1461            let shares = denomination_split(v, &sk, rid, 1, van);
1462            assert_eq!(
1463                shares.iter().sum::<u64>(),
1464                v,
1465                "sum invariant violated for {}",
1466                v
1467            );
1468        }
1469    }
1470
1471    #[test]
1472    fn denom_split_all_shares_in_range() {
1473        let sk = test_sk();
1474        let rid = test_round_id();
1475        let van = test_van();
1476        let test_values: [u64; 8] = [
1477            1,
1478            10_000,
1479            1_000_000,
1480            8_234_567,
1481            15_000_000,
1482            20_000_000,
1483            80_000_000,
1484            168_000_000,
1485        ];
1486        for &v in &test_values {
1487            let shares = denomination_split(v, &sk, rid, 1, van);
1488            for (i, &s) in shares.iter().enumerate() {
1489                assert!(
1490                    s < (1u64 << 30),
1491                    "share {} = {} exceeds 2^30 for {}",
1492                    i,
1493                    s,
1494                    v
1495                );
1496            }
1497        }
1498    }
1499
1500    // ---- remainder randomization tests ----
1501
1502    #[test]
1503    fn remainder_is_deterministic() {
1504        let sk = test_sk();
1505        let rid = test_round_id();
1506        let van = test_van();
1507        let a = denomination_split(999, &sk, rid, 1, van);
1508        let b = denomination_split(999, &sk, rid, 1, van);
1509        assert_eq!(a, b);
1510    }
1511
1512    #[test]
1513    fn remainder_differs_across_proposals() {
1514        // Same balance, different proposal_id → same denoms, different random remainder
1515        let sk = test_sk();
1516        let rid = test_round_id();
1517        let van = test_van();
1518        let a = denomination_split(999, &sk, rid, 1, van);
1519        let b = denomination_split(999, &sk, rid, 2, van);
1520        show("999 ballots, proposal 1", &a);
1521        show("999 ballots, proposal 2", &b);
1522        assert_eq!(a[0..9], b[0..9], "denomination slots should be identical");
1523        assert_ne!(
1524            a[9..16],
1525            b[9..16],
1526            "remainder should differ across proposals"
1527        );
1528    }
1529
1530    #[test]
1531    fn remainder_differs_across_vans() {
1532        // Same balance, different VAN → same denoms, different random remainder
1533        let sk = test_sk();
1534        let rid = test_round_id();
1535        let van_a = pallas::Base::from(0xAAAA_u64);
1536        let van_b = pallas::Base::from(0xBBBB_u64);
1537        let a = denomination_split(999, &sk, rid, 1, van_a);
1538        let b = denomination_split(999, &sk, rid, 1, van_b);
1539        show("999 ballots, VAN A", &a);
1540        show("999 ballots, VAN B", &b);
1541        assert_eq!(a[0..9], b[0..9], "denomination slots should be identical");
1542        assert_ne!(a[9..16], b[9..16], "remainder should differ across VANs");
1543    }
1544
1545    // ---- deterministic_shuffle tests ----
1546
1547    #[test]
1548    fn shuffle_preserves_sum() {
1549        let sk = test_sk();
1550        let round_id = test_round_id();
1551        let van = test_van();
1552        let mut shares = denomination_split(8_234_567, &sk, round_id, 1, van);
1553        let sum_before = shares.iter().sum::<u64>();
1554        deterministic_shuffle(&mut shares, &sk, round_id, 1, van);
1555        assert_eq!(shares.iter().sum::<u64>(), sum_before);
1556    }
1557
1558    #[test]
1559    fn shuffle_preserves_multiset() {
1560        let sk = test_sk();
1561        let round_id = test_round_id();
1562        let van = test_van();
1563        let original = denomination_split(4_800, &sk, round_id, 1, van);
1564        let mut shuffled = original;
1565        deterministic_shuffle(&mut shuffled, &sk, round_id, 1, van);
1566        let mut sorted_orig = original;
1567        sorted_orig.sort();
1568        let mut sorted_shuf = shuffled;
1569        sorted_shuf.sort();
1570        assert_eq!(sorted_orig, sorted_shuf, "shuffle must be a permutation");
1571    }
1572
1573    #[test]
1574    fn shuffle_is_deterministic() {
1575        let sk = test_sk();
1576        let round_id = test_round_id();
1577        let van = test_van();
1578        let mut a = denomination_split(4_800, &sk, round_id, 1, van);
1579        let mut b = denomination_split(4_800, &sk, round_id, 1, van);
1580        deterministic_shuffle(&mut a, &sk, round_id, 1, van);
1581        deterministic_shuffle(&mut b, &sk, round_id, 1, van);
1582        assert_eq!(a, b, "same inputs must produce same permutation");
1583    }
1584
1585    #[test]
1586    fn shuffle_differs_across_proposals() {
1587        let sk = test_sk();
1588        let round_id = test_round_id();
1589        let van = test_van();
1590        let mut a = denomination_split(4_800, &sk, round_id, 1, van);
1591        let mut b = denomination_split(4_800, &sk, round_id, 1, van);
1592        deterministic_shuffle(&mut a, &sk, round_id, 1, van);
1593        deterministic_shuffle(&mut b, &sk, round_id, 2, van);
1594        assert_ne!(
1595            a, b,
1596            "different proposals should produce different permutations"
1597        );
1598    }
1599
1600    #[test]
1601    fn shuffle_differs_across_vans() {
1602        let sk = test_sk();
1603        let round_id = test_round_id();
1604        let van_a = pallas::Base::from(0xAAAA_u64);
1605        let van_b = pallas::Base::from(0xBBBB_u64);
1606        let mut a = denomination_split(4_800, &sk, round_id, 1, van_a);
1607        let mut b = denomination_split(4_800, &sk, round_id, 1, van_b);
1608        deterministic_shuffle(&mut a, &sk, round_id, 1, van_a);
1609        deterministic_shuffle(&mut b, &sk, round_id, 1, van_b);
1610        assert_ne!(a, b, "different VANs should produce different permutations");
1611    }
1612
1613    #[test]
1614    fn shuffle_actually_reorders() {
1615        let sk = test_sk();
1616        let round_id = test_round_id();
1617        let van = test_van();
1618        let original = denomination_split(4_800, &sk, round_id, 1, van);
1619        let mut shuffled = original;
1620        deterministic_shuffle(&mut shuffled, &sk, round_id, 1, van);
1621        assert_ne!(
1622            original, shuffled,
1623            "shuffle should reorder (vanishingly unlikely to be identity for 12 non-zero shares)"
1624        );
1625    }
1626
1627    #[test]
1628    fn prove_error_maps_into_build_error() {
1629        let err =
1630            VoteProofBuildError::from(ProveError::Halo2(halo2_proofs::plonk::Error::Synthesis));
1631
1632        assert!(matches!(err, VoteProofBuildError::Prove(_)));
1633    }
1634}