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.
5//!
6//! El Gamal encryption randomness and share blind factors are derived
7//! deterministically via a Blake2b-512 PRF keyed by the spending key
8//! and bound to the specific VAN being spent, enabling crash recovery
9//! without persisting secrets and preventing nonce reuse across VANs.
10
11use std::{string::String, vec::Vec};
12
13use ff::{Field, FromUniformBytes, PrimeField};
14use group::{Curve, GroupEncoding};
15use halo2_proofs::circuit::Value;
16use orchard::keys::{FullViewingKey, Scope, SpendAuthorizingKey, SpendingKey};
17use pasta_curves::{
18    arithmetic::{Coordinates, CurveAffine},
19    pallas,
20};
21
22use super::{
23    circuit::{
24        van_integrity_hash, van_nullifier_hash, vote_commitment_hash, Circuit, Instance,
25        MAX_PROPOSAL_ID,
26    },
27    prove::create_vote_proof,
28};
29use crate::{
30    domain_tags,
31    gadgets::elgamal::{base_to_scalar, spend_auth_g_affine},
32    params::{BALLOT_DIVISOR, VOTE_COMM_TREE_DEPTH},
33    shares_hash::{share_commitment, shares_hash},
34    ProveError,
35};
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 ak_point = (spend_auth_g_affine() * ask_raw).to_affine();
343    let ak_bytes = ak_point.to_bytes();
344
345    // If the sign bit of ak is 1, the real ask was negated.
346    if (ak_bytes.as_ref()[31] >> 7) == 1 {
347        -ask_raw
348    } else {
349        ask_raw
350    }
351}
352
353/// Core PRF: BLAKE2b-512 bound to the spending key with voting-specific
354/// personalization and domain-separated inputs.
355///
356/// `PRF(sk, domain, round_id, proposal_id, van_commitment, share_index)`
357///   = BLAKE2b-512("ZcashVote_Expand", sk || domain || round_id || proposal_id_le64 || van_commitment || share_index_u8)
358///
359/// The `van_commitment` field binds the derivation to a specific VAN.
360/// Without it, a user with multiple VANs (from >5 notes in Phase 1)
361/// voting on the same proposal would derive identical El Gamal nonces,
362/// enabling a classic nonce-reuse attack on the ciphertexts.
363fn vote_share_prf(
364    sk: &SpendingKey,
365    domain: u8,
366    round_id: pallas::Base,
367    proposal_id: u64,
368    van_commitment: pallas::Base,
369    share_index: u8,
370) -> [u8; 64] {
371    *blake2b_simd::Params::new()
372        .hash_length(64)
373        .personal(domain_tags::VOTE_PRF_PERSONALIZATION)
374        .to_state()
375        .update(sk.to_bytes())
376        .update(&[domain])
377        .update(&round_id.to_repr())
378        .update(&proposal_id.to_le_bytes())
379        .update(&van_commitment.to_repr())
380        .update(&[share_index])
381        .finalize()
382        .as_array()
383}
384
385/// Derive deterministic El Gamal randomness `r_i` for a share.
386///
387/// Returns a non-zero `pallas::Base` element that is also a valid `pallas::Scalar`.
388/// We reduce mod p_base first; since p_base < q_scalar on the Pallas curve,
389/// every Base element is representable as a Scalar.
390fn derive_share_randomness(
391    sk: &SpendingKey,
392    round_id: pallas::Base,
393    proposal_id: u64,
394    van_commitment: pallas::Base,
395    share_index: u8,
396) -> pallas::Base {
397    let hash = vote_share_prf(
398        sk,
399        domain_tags::VOTE_PRF_DOMAIN_ELGAMAL,
400        round_id,
401        proposal_id,
402        van_commitment,
403        share_index,
404    );
405    let r = pallas::Base::from_uniform_bytes(&hash);
406    if bool::from(r.is_zero()) {
407        // Preserve deterministic derivation while satisfying the circuit's
408        // non-zero randomness gate in the negligible exact-zero case.
409        return pallas::Base::one();
410    }
411    debug_assert!(base_to_scalar(r).is_some(), "p < q guarantees Base→Scalar");
412    r
413}
414
415/// Derive deterministic blind factor `blind_i` for a share commitment.
416fn derive_share_blind(
417    sk: &SpendingKey,
418    round_id: pallas::Base,
419    proposal_id: u64,
420    van_commitment: pallas::Base,
421    share_index: u8,
422) -> pallas::Base {
423    let hash = vote_share_prf(
424        sk,
425        domain_tags::VOTE_PRF_DOMAIN_BLIND,
426        round_id,
427        proposal_id,
428        van_commitment,
429        share_index,
430    );
431    pallas::Base::from_uniform_bytes(&hash)
432}
433
434/// Deterministic Fisher-Yates shuffle of the shares array.
435///
436/// Prevents the sorted denomination order from leaking balance information
437/// through share indices. An adversary seeing (index, decrypted_value)
438/// would otherwise learn the denomination's rank in the sorted
439/// decomposition, tightening its estimate of the voter's total balance.
440/// Shuffling makes each index equally likely to hold any denomination.
441///
442/// The permutation is derived from the same PRF used for El Gamal randomness
443/// and blind factors, with a distinct shuffle domain separator.
444/// Share index 0 is used for the PRF call (the seed depends on the VAN, round,
445/// and proposal — not on the permutation step) to produce 64 bytes of
446/// pseudorandom data, which is consumed 4 bytes at a time for modular indices.
447fn deterministic_shuffle(
448    shares: &mut [u64; NUM_SHARES],
449    sk: &SpendingKey,
450    round_id: pallas::Base,
451    proposal_id: u64,
452    van_commitment: pallas::Base,
453) {
454    // The share index is hardcoded to 0 here because the shuffle
455    // function only needs one PRF call to seed the entire Fisher
456    // Yates shuffle. It doesn't need per-share derivations.
457    let seed = vote_share_prf(
458        sk,
459        domain_tags::VOTE_PRF_DOMAIN_SHUFFLE,
460        round_id,
461        proposal_id,
462        van_commitment,
463        0,
464    );
465    for i in (1..NUM_SHARES).rev() {
466        // Each iteration consumes the next 4-byte slice of the seed as a
467        // random u32: i=15 reads seed[0..4], i=14 reads seed[4..8], …,
468        // i=1 reads seed[56..60] (15 draws × 4 bytes = 60 of the 64-byte seed).
469        let byte_offset = (NUM_SHARES - 1 - i) * 4;
470        let rand_bytes: [u8; 4] = seed[byte_offset..byte_offset + 4]
471            .try_into()
472            .expect("64-byte seed has room for 15 × 4-byte draws");
473        let j = (u32::from_le_bytes(rand_bytes) as usize) % (i + 1);
474        shares.swap(i, j);
475    }
476}
477
478/// Build a real vote proof (ZKP #2) from delegation key material.
479///
480/// This function constructs the full vote proof circuit, computes all
481/// public inputs, and generates a Halo2 proof.
482///
483/// # Arguments
484///
485/// * `sk` - The SpendingKey used during delegation (ZKP #1).
486/// * `address_index` - The diversifier index of the output recipient
487///   address used in delegation (typically 1).
488/// * `total_note_value` - Sum of delegated note values in raw zatoshi (e.g. 15_000_000).
489///   Internally converted to ballot count via floor-division by BALLOT_DIVISOR
490///   (the delegation circuit's condition 8 constrains this relation; see the
491///   delegation README §8 for the precise proven statement).
492/// * `van_comm_rand` - The blinding factor used for the VAN in delegation.
493/// * `voting_round_id` - The active governance round identifier (Pallas base
494///   field element). The caller must authenticate it from the round
495///   announcement.
496/// * `vote_comm_tree_path` - Merkle authentication path (24 siblings) for
497///   the VAN in the vote commitment tree.
498/// * `vote_comm_tree_position` - Leaf position of the VAN in the tree.
499/// * `anchor_height` - Caller-authenticated chain height used by the verifier
500///   or chain to source the vote commitment tree root. The circuit carries this
501///   as a public input but does not derive or constrain the height itself.
502/// * `proposal_id` - Which proposal to vote on (1-indexed, must be in [1, 15]).
503///   The builder checks only this circuit-supported range; the caller must
504///   ensure the proposal is active for `voting_round_id`.
505/// * `vote_decision` - The voter's choice.
506/// * `ea_pk` - Election authority public key (Pallas affine point). The caller
507///   must authenticate this against the active round's governance announcement;
508///   the builder and circuit only prove encryption under the supplied key.
509/// * `alpha_v` - Spend auth randomizer for the voting hotkey. The caller
510///   retains this to sign the sighash with `rsk_v = ask_v.randomize(&alpha_v)`.
511///
512/// # Caller contract
513///
514/// `alpha_v` is a secret, one-time spend-auth randomizer and MUST be drawn
515/// from a CSPRNG such as `OsRng` for each vote proof. `van_comm_rand` is the
516/// secret VAN commitment blinding factor originally used by
517/// `delegation::build_delegation_bundle`; pass the retained value unchanged.
518/// `voting_round_id`, `anchor_height`, `proposal_id`, `ea_pk`, and the vote
519/// commitment tree witness are authenticated session parameters: the builder
520/// constrains proofs to the supplied values but cannot prove they came from the
521/// intended chain state or governance announcement.
522///
523/// El Gamal encryption randomness (`r_i`) and share blind factors (`blind_i`)
524/// are derived deterministically from `sk`, `voting_round_id`, `proposal_id`,
525/// `vote_authority_note_old`, and each share's index via a Blake2b-512 PRF.
526/// Including the VAN commitment prevents nonce reuse when the same user has
527/// multiple VANs (from >5 notes in Phase 1) voting on the same proposal.
528/// This allows the client to re-derive the same secrets after a crash without
529/// persisting them.
530///
531/// **Expensive**: K=13 proof generation takes ~30-60 seconds in release mode.
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
557    // ---- Key derivation (matches delegation's key hierarchy) ----
558
559    let vsk = extract_vsk(sk);
560    let fvk: FullViewingKey = sk.into();
561    let vsk_nk = fvk.nk().inner();
562    let rivk_v = fvk.rivk(Scope::External).inner();
563
564    let address = fvk.address_at(address_index, Scope::External);
565    let vpk_g_d = address.g_d();
566    let vpk_pk_d = address.pk_d().inner();
567    let vpk_g_d_affine = vpk_g_d.to_affine();
568    let vpk_pk_d_affine = vpk_pk_d.to_affine();
569
570    let vpk_g_d_coords = pallas_coordinates(vpk_g_d_affine)
571        .expect("orchard address g_d is non-identity by construction");
572    let vpk_pk_d_coords = pallas_coordinates(vpk_pk_d_affine)
573        .expect("orchard address pk_d is non-identity by construction");
574    let vpk_g_d_x = *vpk_g_d_coords.x();
575    let vpk_pk_d_x = *vpk_pk_d_coords.x();
576
577    // ---- Fast key-chain consistency checks (instant, no circuit) ----
578    {
579        use core::iter;
580        use group::ff::PrimeFieldBits;
581        use halo2_gadgets::sinsemilla::primitives::CommitDomain;
582        use orchard::constants::{fixed_bases::COMMIT_IVK_PERSONALIZATION, L_ORCHARD_BASE};
583
584        // Check 1: [vsk] * SpendAuthG must match the ak from the FullViewingKey.
585        let ak_from_vsk = (spend_auth_g_affine() * vsk).to_affine();
586        let fvk_bytes = fvk.to_bytes();
587        let ak_from_fvk_bytes: [u8; 32] = fvk_bytes[0..32].try_into().unwrap();
588        let ak_from_fvk: pallas::Affine = {
589            let opt: Option<pallas::Point> = pallas::Point::from_bytes(&ak_from_fvk_bytes).into();
590            opt.expect("ak from fvk must be a valid point").to_affine()
591        };
592        assert_eq!(
593            ak_from_vsk, ak_from_fvk,
594            "extract_vsk bug: [vsk]*SpendAuthG != ak from FullViewingKey"
595        );
596
597        // Check 2: CommitIvk(ak_x, nk, rivk) must produce an ivk where [ivk]*g_d == pk_d.
598        let ak_from_vsk_coords = pallas_coordinates(ak_from_vsk)
599            .expect("valid Orchard spending keys have nonzero spend authorizing keys");
600        let ak_x = *ak_from_vsk_coords.x();
601        let domain = CommitDomain::new(COMMIT_IVK_PERSONALIZATION);
602        let ivk = domain
603            .short_commit(
604                iter::empty()
605                    .chain(ak_x.to_le_bits().iter().by_vals().take(L_ORCHARD_BASE))
606                    .chain(vsk_nk.to_le_bits().iter().by_vals().take(L_ORCHARD_BASE)),
607                &rivk_v,
608            )
609            .expect("CommitIvk must not produce bottom");
610        let ivk_scalar = base_to_scalar(ivk).expect("ivk must be convertible to scalar");
611        let pk_d_derived = (*vpk_g_d * ivk_scalar).to_affine();
612        assert_eq!(
613            pk_d_derived, vpk_pk_d_affine,
614            "CommitIvk chain mismatch: [ivk]*g_d != pk_d from address"
615        );
616    }
617
618    // ---- Proposal authority ----
619
620    let proposal_authority_old = pallas::Base::from(proposal_authority_old_u64);
621    let one_shifted = pallas::Base::from(1u64 << proposal_id);
622    let proposal_authority_new = proposal_authority_old - one_shifted;
623
624    // ---- Ballot scaling (must match ZKP #1's BALLOT_DIVISOR) ----
625
626    let num_ballots = total_note_value / BALLOT_DIVISOR;
627    let num_ballots_base = pallas::Base::from(num_ballots);
628
629    // ---- VAN integrity hashes ----
630    // The VAN commitment hashes num_ballots (not raw zatoshi), matching
631    // the delegation circuit (ZKP #1 condition 7).
632
633    let vote_authority_note_old = van_integrity_hash(
634        vpk_g_d_x,
635        vpk_pk_d_x,
636        num_ballots_base,
637        voting_round_id,
638        proposal_authority_old,
639        van_comm_rand,
640    );
641
642    let van_nullifier = van_nullifier_hash(vsk_nk, voting_round_id, vote_authority_note_old);
643
644    let vote_authority_note_new = van_integrity_hash(
645        vpk_g_d_x,
646        vpk_pk_d_x,
647        num_ballots_base,
648        voting_round_id,
649        proposal_authority_new,
650        van_comm_rand,
651    );
652
653    // ---- Shares (denomination-based split of num_ballots into 16 parts) ----
654    // Each share must be in [0, 2^30) for the range check.
655    // Shares sum to num_ballots (ballot count), not raw zatoshi.
656
657    let shares_u64: [u64; 16] = if single_share {
658        // Last-moment mode: put entire weight in share[0], rest are zero.
659        // Only one share is revealed to the helper, minimizing latency
660        // when voting near the deadline.
661        let mut s = [0u64; 16];
662        s[0] = num_ballots;
663        s
664    } else {
665        let mut s = denomination_split(
666            num_ballots,
667            sk,
668            voting_round_id,
669            proposal_id,
670            vote_authority_note_old,
671        );
672        deterministic_shuffle(
673            &mut s,
674            sk,
675            voting_round_id,
676            proposal_id,
677            vote_authority_note_old,
678        );
679        s
680    };
681
682    // Verify all shares are in range
683    for (i, &s) in shares_u64.iter().enumerate() {
684        if s >= (1u64 << 30) {
685            return Err(VoteProofBuildError::InvalidShares(format!(
686                "share {} = {} exceeds 2^30",
687                i, s
688            )));
689        }
690    }
691
692    let shares_base: [pallas::Base; 16] =
693        core::array::from_fn(|i| pallas::Base::from(shares_u64[i]));
694
695    // ---- El Gamal encryption of shares ----
696    //
697    // Encrypts each share and captures both point coordinates for circuit
698    // constraints plus the full compressed point bytes for reveal-share payloads.
699
700    let g = spend_auth_g_affine();
701    let mut enc_c1_x = [pallas::Base::zero(); 16];
702    let mut enc_c2_x = [pallas::Base::zero(); 16];
703    let mut enc_c1_y = [pallas::Base::zero(); 16];
704    let mut enc_c2_y = [pallas::Base::zero(); 16];
705    let mut share_randomness = [pallas::Base::zero(); 16];
706    let mut enc_share_outputs: [EncryptedShareOutput; 16] =
707        core::array::from_fn(|i| EncryptedShareOutput {
708            c1: [0u8; 32],
709            c2: [0u8; 32],
710            share_index: i as u32,
711            plaintext_value: shares_u64[i],
712            randomness: [0u8; 32],
713        });
714
715    for i in 0..16 {
716        let r = derive_share_randomness(
717            sk,
718            voting_round_id,
719            proposal_id,
720            vote_authority_note_old,
721            i as u8,
722        );
723        share_randomness[i] = r;
724        let r_scalar =
725            base_to_scalar(r).expect("derive_share_randomness guarantees nonzero scalar-range");
726        let v_scalar = base_to_scalar(shares_base[i]).expect("share value in range");
727
728        let c1_point = (g * r_scalar).to_affine();
729        let c2_point = (g * v_scalar + ea_pk * r_scalar).to_affine();
730
731        let c1_coords = encrypted_share_coordinates(c1_point, i, "c1")?;
732        let c2_coords = encrypted_share_coordinates(c2_point, i, "c2")?;
733
734        enc_c1_x[i] = *c1_coords.x();
735        enc_c2_x[i] = *c2_coords.x();
736        enc_c1_y[i] = *c1_coords.y();
737        enc_c2_y[i] = *c2_coords.y();
738
739        enc_share_outputs[i].c1 = c1_point.to_bytes();
740        enc_share_outputs[i].c2 = c2_point.to_bytes();
741        enc_share_outputs[i].randomness = r.to_repr();
742    }
743
744    let share_blinds: [pallas::Base; 16] = core::array::from_fn(|i| {
745        derive_share_blind(
746            sk,
747            voting_round_id,
748            proposal_id,
749            vote_authority_note_old,
750            i as u8,
751        )
752    });
753    let share_comms: [pallas::Base; 16] = core::array::from_fn(|i| {
754        share_commitment(
755            share_blinds[i],
756            enc_c1_x[i],
757            enc_c2_x[i],
758            enc_c1_y[i],
759            enc_c2_y[i],
760        )
761    });
762    let shares_hash_val = shares_hash(share_blinds, enc_c1_x, enc_c2_x, enc_c1_y, enc_c2_y);
763
764    // ---- Condition 4: r_vpk = ak + [alpha_v] * G = [vsk + alpha_v] * G ----
765    // alpha_v is now provided by the caller so they can sign with rsk_v.
766    let r_vpk = (spend_auth_g_affine() * (vsk + 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 crate::protocol_hash::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 = (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 = (spend_auth_g_affine() * pallas::Scalar::from(42u64)).to_affine();
1034        let err = build_vote_proof_from_delegation(
1035            &sk,
1036            1,
1037            BALLOT_DIVISOR,
1038            test_van(),
1039            test_round_id(),
1040            [pallas::Base::from(0u64); VOTE_COMM_TREE_DEPTH],
1041            0,
1042            123,
1043            1,
1044            1,
1045            ea_pk,
1046            -extract_vsk(&sk),
1047            65535,
1048            true,
1049        )
1050        .expect_err("alpha_v = -vsk should make r_vpk the identity");
1051
1052        assert!(matches!(
1053            err,
1054            VoteProofBuildError::InvalidRandomizedVotingPublicKey
1055        ));
1056    }
1057
1058    #[test]
1059    fn derive_share_randomness_is_deterministic() {
1060        let sk = test_sk();
1061        let round_id = test_round_id();
1062        let van = test_van();
1063        let a = derive_share_randomness(&sk, round_id, 1, van, 0);
1064        let b = derive_share_randomness(&sk, round_id, 1, van, 0);
1065        assert_eq!(a, b);
1066    }
1067
1068    #[test]
1069    fn derive_share_blind_is_deterministic() {
1070        let sk = test_sk();
1071        let round_id = test_round_id();
1072        let van = test_van();
1073        let a = derive_share_blind(&sk, round_id, 1, van, 0);
1074        let b = derive_share_blind(&sk, round_id, 1, van, 0);
1075        assert_eq!(a, b);
1076    }
1077
1078    #[test]
1079    fn derive_share_randomness_is_nonzero_valid_scalar() {
1080        let sk = test_sk();
1081        let round_id = test_round_id();
1082        let van = test_van();
1083        for i in 0..16u8 {
1084            let r = derive_share_randomness(&sk, round_id, 1, van, i);
1085            assert!(
1086                bool::from(!r.is_zero()),
1087                "r_{} must be non-zero for the circuit hardening gate",
1088                i
1089            );
1090            assert!(
1091                base_to_scalar(r).is_some(),
1092                "r_{} must be convertible to scalar",
1093                i
1094            );
1095        }
1096    }
1097
1098    #[test]
1099    fn different_share_index_gives_different_values() {
1100        let sk = test_sk();
1101        let round_id = test_round_id();
1102        let van = test_van();
1103        let r0 = derive_share_randomness(&sk, round_id, 1, van, 0);
1104        let r1 = derive_share_randomness(&sk, round_id, 1, van, 1);
1105        assert_ne!(r0, r1);
1106
1107        let b0 = derive_share_blind(&sk, round_id, 1, van, 0);
1108        let b1 = derive_share_blind(&sk, round_id, 1, van, 1);
1109        assert_ne!(b0, b1);
1110    }
1111
1112    #[test]
1113    fn different_proposal_id_gives_different_values() {
1114        let sk = test_sk();
1115        let round_id = test_round_id();
1116        let van = test_van();
1117        let r_p1 = derive_share_randomness(&sk, round_id, 1, van, 0);
1118        let r_p2 = derive_share_randomness(&sk, round_id, 2, van, 0);
1119        assert_ne!(r_p1, r_p2);
1120    }
1121
1122    #[test]
1123    fn different_round_id_gives_different_values() {
1124        let sk = test_sk();
1125        let van = test_van();
1126        let r_a = derive_share_randomness(&sk, pallas::Base::from(1u64), 1, van, 0);
1127        let r_b = derive_share_randomness(&sk, pallas::Base::from(2u64), 1, van, 0);
1128        assert_ne!(r_a, r_b);
1129    }
1130
1131    #[test]
1132    fn randomness_and_blind_differ_for_same_inputs() {
1133        let sk = test_sk();
1134        let round_id = test_round_id();
1135        let van = test_van();
1136        let r = derive_share_randomness(&sk, round_id, 1, van, 0);
1137        let b = derive_share_blind(&sk, round_id, 1, van, 0);
1138        assert_ne!(r, b, "domain separation must prevent r == blind");
1139    }
1140
1141    #[test]
1142    fn all_16_shares_are_distinct() {
1143        let sk = test_sk();
1144        let round_id = test_round_id();
1145        let van = test_van();
1146        let randoms: Vec<_> = (0..16u8)
1147            .map(|i| derive_share_randomness(&sk, round_id, 1, van, i))
1148            .collect();
1149        let blinds: Vec<_> = (0..16u8)
1150            .map(|i| derive_share_blind(&sk, round_id, 1, van, i))
1151            .collect();
1152        for i in 0..16 {
1153            for j in (i + 1)..16 {
1154                assert_ne!(randoms[i], randoms[j], "r_{} == r_{}", i, j);
1155                assert_ne!(blinds[i], blinds[j], "blind_{} == blind_{}", i, j);
1156            }
1157        }
1158    }
1159
1160    #[test]
1161    fn different_van_commitment_gives_different_values() {
1162        let sk = test_sk();
1163        let round_id = test_round_id();
1164        let van_a = pallas::Base::from(0xAAAA_u64);
1165        let van_b = pallas::Base::from(0xBBBB_u64);
1166        for i in 0..16u8 {
1167            let r_a = derive_share_randomness(&sk, round_id, 1, van_a, i);
1168            let r_b = derive_share_randomness(&sk, round_id, 1, van_b, i);
1169            assert_ne!(r_a, r_b, "r_{} must differ across VANs", i);
1170
1171            let b_a = derive_share_blind(&sk, round_id, 1, van_a, i);
1172            let b_b = derive_share_blind(&sk, round_id, 1, van_b, i);
1173            assert_ne!(b_a, b_b, "blind_{} must differ across VANs", i);
1174        }
1175    }
1176
1177    // ---- denomination_split tests ----
1178    //
1179    // Visual key:
1180    //   D = denomination (standard value, blends across voters)
1181    //   R = random (PRF-derived, prevents exact balance fingerprint)
1182    //   0 = zero (encrypted with fresh randomness, indistinguishable from non-zero)
1183    //
1184    // Layout: [0..8] = greedy denom slots | [9..15] = remainder / random slots
1185    // After shuffle, positions are randomized — these show the pre-shuffle array.
1186
1187    /// Helper: print shares array for visual inspection during --nocapture runs.
1188    fn show(label: &str, shares: &[u64; 16]) {
1189        let parts: Vec<String> = shares
1190            .iter()
1191            .map(|&v| {
1192                if v == 0 {
1193                    "0".into()
1194                } else if v >= 1_000_000 {
1195                    format!("{}M", v / 1_000_000)
1196                } else if v >= 1_000 {
1197                    format!("{}K", v / 1_000)
1198                } else {
1199                    format!("{}", v)
1200                }
1201            })
1202            .collect();
1203        std::eprintln!("  {}: [{}]", label, parts.join(", "));
1204    }
1205
1206    #[test]
1207    fn denom_split_zero_ballots() {
1208        // 0 ballots — all slots empty
1209        // [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
1210        let sk = test_sk();
1211        let rid = test_round_id();
1212        let van = test_van();
1213        let shares = denomination_split(0, &sk, rid, 1, van);
1214        show("0 ballots", &shares);
1215        assert_eq!(shares, [0; 16]);
1216    }
1217
1218    #[test]
1219    fn denom_split_single_ballot() {
1220        // 1 ballot (0.125 ZEC) — smallest denomination
1221        // [D:1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
1222        let sk = test_sk();
1223        let rid = test_round_id();
1224        let van = test_van();
1225        let shares = denomination_split(1, &sk, rid, 1, van);
1226        show("1 ballot (0.125 ZEC)", &shares);
1227        assert_eq!(shares[0], 1);
1228        for i in 1..16 {
1229            assert_eq!(shares[i], 0);
1230        }
1231    }
1232
1233    #[test]
1234    fn denom_split_sub_zec() {
1235        // 4 ballots (0.5 ZEC)
1236        // [D:1, D:1, D:1, D:1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
1237        let sk = test_sk();
1238        let rid = test_round_id();
1239        let van = test_van();
1240        let shares = denomination_split(4, &sk, rid, 1, van);
1241        show("4 ballots (0.5 ZEC)", &shares);
1242        assert_eq!(shares[0..4], [1; 4]);
1243        for i in 4..16 {
1244            assert_eq!(shares[i], 0);
1245        }
1246    }
1247
1248    #[test]
1249    fn denom_split_one_zec() {
1250        // 8 ballots (1 ZEC)
1251        // [D:1, D:1, D:1, D:1, D:1, D:1, D:1, D:1, 0, 0, 0, 0, 0, 0, 0, 0]
1252        let sk = test_sk();
1253        let rid = test_round_id();
1254        let van = test_van();
1255        let shares = denomination_split(8, &sk, rid, 1, van);
1256        show("8 ballots (1 ZEC)", &shares);
1257        assert_eq!(shares[0..8], [1; 8]);
1258        for i in 8..16 {
1259            assert_eq!(shares[i], 0);
1260        }
1261    }
1262
1263    #[test]
1264    fn denom_split_small_balance() {
1265        // 50 ballots (6.25 ZEC) — 5 denom slots, all standard
1266        // [D:10, D:10, D:10, D:10, D:10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
1267        let sk = test_sk();
1268        let rid = test_round_id();
1269        let van = test_van();
1270        let shares = denomination_split(50, &sk, rid, 1, van);
1271        show("50 ballots (6.25 ZEC)", &shares);
1272        assert_eq!(shares[0..5], [10; 5]);
1273        for i in 5..16 {
1274            assert_eq!(shares[i], 0);
1275        }
1276    }
1277
1278    #[test]
1279    fn denom_split_all_denoms_exact() {
1280        // 11,111 ballots (1,388.9 ZEC) — one of each denom, no remainder
1281        // [D:10K, D:1K, D:100, D:10, D:1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
1282        let sk = test_sk();
1283        let rid = test_round_id();
1284        let van = test_van();
1285        let shares = denomination_split(11_111, &sk, rid, 1, van);
1286        show("11,111 ballots (1,388.9 ZEC)", &shares);
1287        assert_eq!(shares[0], 10_000);
1288        assert_eq!(shares[1], 1_000);
1289        assert_eq!(shares[2], 100);
1290        assert_eq!(shares[3], 10);
1291        assert_eq!(shares[4], 1);
1292        for i in 5..16 {
1293            assert_eq!(shares[i], 0);
1294        }
1295    }
1296
1297    #[test]
1298    fn denom_split_medium_holder_with_remainder() {
1299        // 4,800 ballots (600 ZEC) — greedy fills 9 (4×1K + 5×100 = 4,500), remainder 300
1300        // [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]
1301        //  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^  ^^^^^^^^^^^^^^^^^^^^
1302        //  9 denomination slots (4,500)                                  7 random slots (300)
1303        let sk = test_sk();
1304        let rid = test_round_id();
1305        let van = test_van();
1306        let shares = denomination_split(4_800, &sk, rid, 1, van);
1307        show("4,800 ballots (600 ZEC)", &shares);
1308        assert_eq!(shares[0..4], [1_000; 4]);
1309        assert_eq!(shares[4..9], [100; 5]);
1310        let remainder_sum: u64 = shares[9..16].iter().sum();
1311        assert_eq!(remainder_sum, 300);
1312        for i in 9..16 {
1313            assert!(shares[i] > 0, "remainder slot {} should be non-zero", i);
1314        }
1315        assert_eq!(shares.iter().sum::<u64>(), 4_800);
1316    }
1317
1318    #[test]
1319    fn denom_split_high_hamming_weight() {
1320        // 999 ballots (124.875 ZEC) — greedy fills 9 (9×100 = 900), remainder 99
1321        // [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]
1322        //  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^   ^^^^^^^^^^^^^^^^^^^^
1323        //  9 denomination slots (900)                                       7 random slots (99)
1324        let sk = test_sk();
1325        let rid = test_round_id();
1326        let van = test_van();
1327        let shares = denomination_split(999, &sk, rid, 1, van);
1328        show("999 ballots (124.875 ZEC)", &shares);
1329        assert_eq!(shares[0..9], [100; 9]);
1330        let remainder_sum: u64 = shares[9..16].iter().sum();
1331        assert_eq!(remainder_sum, 99);
1332        for i in 9..16 {
1333            assert!(shares[i] > 0, "remainder slot {} should be non-zero", i);
1334        }
1335    }
1336
1337    #[test]
1338    fn denom_split_exact_denomination_match() {
1339        // 3M ballots (375 ZEC) — 3 denom slots, no remainder
1340        // [D:1M, D:1M, D:1M, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
1341        let sk = test_sk();
1342        let rid = test_round_id();
1343        let van = test_van();
1344        let shares = denomination_split(3_000_000, &sk, rid, 1, van);
1345        show("3M ballots (375 ZEC)", &shares);
1346        assert_eq!(shares[0..3], [1_000_000; 3]);
1347        for i in 3..16 {
1348            assert_eq!(shares[i], 0);
1349        }
1350    }
1351
1352    #[test]
1353    fn denom_split_8m_ballots() {
1354        // 8M ballots (1M ZEC) — 8 denom slots, no remainder
1355        // [D:1M, D:1M, D:1M, D:1M, D:1M, D:1M, D:1M, D:1M, 0, 0, 0, 0, 0, 0, 0, 0]
1356        let sk = test_sk();
1357        let rid = test_round_id();
1358        let van = test_van();
1359        let shares = denomination_split(8_000_000, &sk, rid, 1, van);
1360        show("8M ballots (1M ZEC)", &shares);
1361        assert_eq!(shares[0..8], [1_000_000; 8]);
1362        for i in 8..16 {
1363            assert_eq!(shares[i], 0);
1364        }
1365    }
1366
1367    #[test]
1368    fn denom_split_fills_all_9_denom_slots() {
1369        // 90M ballots (11.25M ZEC) — all 9 denom slots filled, no remainder
1370        // [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]
1371        let sk = test_sk();
1372        let rid = test_round_id();
1373        let van = test_van();
1374        let shares = denomination_split(90_000_000, &sk, rid, 1, van);
1375        show("90M ballots (11.25M ZEC)", &shares);
1376        assert_eq!(shares[0..9], [10_000_000; 9]);
1377        for i in 9..16 {
1378            assert_eq!(shares[i], 0);
1379        }
1380    }
1381
1382    #[test]
1383    fn denom_split_overflow_into_remainder() {
1384        // 100M ballots (12.5M ZEC) — 9 denom slots full (9×10M), remainder 10M in 7 random slots
1385        // [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]
1386        //  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^    ^^^^^^^^^^^^^^^^^^^^
1387        //  9 denomination slots (90M)                                        7 random slots (10M)
1388        let sk = test_sk();
1389        let rid = test_round_id();
1390        let van = test_van();
1391        let shares = denomination_split(100_000_000, &sk, rid, 1, van);
1392        show("100M ballots (12.5M ZEC)", &shares);
1393        assert_eq!(shares[0..9], [10_000_000; 9]);
1394        let remainder_sum: u64 = shares[9..16].iter().sum();
1395        assert_eq!(remainder_sum, 10_000_000);
1396        for i in 9..16 {
1397            assert!(shares[i] > 0, "remainder slot {} should be non-zero", i);
1398        }
1399    }
1400
1401    #[test]
1402    fn denom_split_mixed_with_remainder() {
1403        // 1,234,567 ballots (154,320.9 ZEC) — 9 denom slots, remainder distributed
1404        // [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]
1405        //  greedy: 1M + 200K + 30K + 3K = 1,233,000                      remainder: 1,567
1406        let sk = test_sk();
1407        let rid = test_round_id();
1408        let van = test_van();
1409        let shares = denomination_split(1_234_567, &sk, rid, 1, van);
1410        show("1,234,567 ballots (154K ZEC)", &shares);
1411        assert_eq!(shares[0], 1_000_000);
1412        assert_eq!(shares[1..3], [100_000; 2]);
1413        assert_eq!(shares[3..6], [10_000; 3]);
1414        assert_eq!(shares[6..9], [1_000; 3]);
1415        let remainder_sum: u64 = shares[9..16].iter().sum();
1416        assert_eq!(remainder_sum, 1_567);
1417        assert_eq!(shares.iter().sum::<u64>(), 1_234_567);
1418    }
1419
1420    #[test]
1421    fn denom_split_small_remainder_fewer_than_free_slots() {
1422        // 10,000,003 ballots — 1 denom slot (10M), remainder 3 across 7 free slots
1423        // [D:10M, R:1, R:1, R:1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
1424        //  remainder 3 < 7 free slots, so only 3 of 7 get a value
1425        let sk = test_sk();
1426        let rid = test_round_id();
1427        let van = test_van();
1428        let shares = denomination_split(10_000_003, &sk, rid, 1, van);
1429        show("10,000,003 ballots", &shares);
1430        assert_eq!(shares[0], 10_000_000);
1431        let remainder_sum: u64 = shares[1..16].iter().sum();
1432        assert_eq!(remainder_sum, 3);
1433        assert_eq!(shares.iter().sum::<u64>(), 10_000_003);
1434    }
1435
1436    // ---- invariant tests ----
1437
1438    #[test]
1439    fn denom_split_sum_invariant() {
1440        let sk = test_sk();
1441        let rid = test_round_id();
1442        let van = test_van();
1443        let test_values: [u64; 14] = [
1444            0,
1445            1,
1446            50,
1447            99,
1448            100,
1449            999,
1450            1_000,
1451            10_000,
1452            100_000,
1453            1_000_000,
1454            8_234_567,
1455            20_000_000,
1456            80_000_000,
1457            168_000_000,
1458        ];
1459        for &v in &test_values {
1460            let shares = denomination_split(v, &sk, rid, 1, van);
1461            assert_eq!(
1462                shares.iter().sum::<u64>(),
1463                v,
1464                "sum invariant violated for {}",
1465                v
1466            );
1467        }
1468    }
1469
1470    #[test]
1471    fn denom_split_all_shares_in_range() {
1472        let sk = test_sk();
1473        let rid = test_round_id();
1474        let van = test_van();
1475        let test_values: [u64; 8] = [
1476            1,
1477            10_000,
1478            1_000_000,
1479            8_234_567,
1480            15_000_000,
1481            20_000_000,
1482            80_000_000,
1483            168_000_000,
1484        ];
1485        for &v in &test_values {
1486            let shares = denomination_split(v, &sk, rid, 1, van);
1487            for (i, &s) in shares.iter().enumerate() {
1488                assert!(
1489                    s < (1u64 << 30),
1490                    "share {} = {} exceeds 2^30 for {}",
1491                    i,
1492                    s,
1493                    v
1494                );
1495            }
1496        }
1497    }
1498
1499    // ---- remainder randomization tests ----
1500
1501    #[test]
1502    fn remainder_is_deterministic() {
1503        let sk = test_sk();
1504        let rid = test_round_id();
1505        let van = test_van();
1506        let a = denomination_split(999, &sk, rid, 1, van);
1507        let b = denomination_split(999, &sk, rid, 1, van);
1508        assert_eq!(a, b);
1509    }
1510
1511    #[test]
1512    fn remainder_differs_across_proposals() {
1513        // Same balance, different proposal_id → same denoms, different random remainder
1514        let sk = test_sk();
1515        let rid = test_round_id();
1516        let van = test_van();
1517        let a = denomination_split(999, &sk, rid, 1, van);
1518        let b = denomination_split(999, &sk, rid, 2, van);
1519        show("999 ballots, proposal 1", &a);
1520        show("999 ballots, proposal 2", &b);
1521        assert_eq!(a[0..9], b[0..9], "denomination slots should be identical");
1522        assert_ne!(
1523            a[9..16],
1524            b[9..16],
1525            "remainder should differ across proposals"
1526        );
1527    }
1528
1529    #[test]
1530    fn remainder_differs_across_vans() {
1531        // Same balance, different VAN → same denoms, different random remainder
1532        let sk = test_sk();
1533        let rid = test_round_id();
1534        let van_a = pallas::Base::from(0xAAAA_u64);
1535        let van_b = pallas::Base::from(0xBBBB_u64);
1536        let a = denomination_split(999, &sk, rid, 1, van_a);
1537        let b = denomination_split(999, &sk, rid, 1, van_b);
1538        show("999 ballots, VAN A", &a);
1539        show("999 ballots, VAN B", &b);
1540        assert_eq!(a[0..9], b[0..9], "denomination slots should be identical");
1541        assert_ne!(a[9..16], b[9..16], "remainder should differ across VANs");
1542    }
1543
1544    // ---- deterministic_shuffle tests ----
1545
1546    #[test]
1547    fn shuffle_preserves_sum() {
1548        let sk = test_sk();
1549        let round_id = test_round_id();
1550        let van = test_van();
1551        let mut shares = denomination_split(8_234_567, &sk, round_id, 1, van);
1552        let sum_before = shares.iter().sum::<u64>();
1553        deterministic_shuffle(&mut shares, &sk, round_id, 1, van);
1554        assert_eq!(shares.iter().sum::<u64>(), sum_before);
1555    }
1556
1557    #[test]
1558    fn shuffle_preserves_multiset() {
1559        let sk = test_sk();
1560        let round_id = test_round_id();
1561        let van = test_van();
1562        let original = denomination_split(4_800, &sk, round_id, 1, van);
1563        let mut shuffled = original;
1564        deterministic_shuffle(&mut shuffled, &sk, round_id, 1, van);
1565        let mut sorted_orig = original;
1566        sorted_orig.sort();
1567        let mut sorted_shuf = shuffled;
1568        sorted_shuf.sort();
1569        assert_eq!(sorted_orig, sorted_shuf, "shuffle must be a permutation");
1570    }
1571
1572    #[test]
1573    fn shuffle_is_deterministic() {
1574        let sk = test_sk();
1575        let round_id = test_round_id();
1576        let van = test_van();
1577        let mut a = denomination_split(4_800, &sk, round_id, 1, van);
1578        let mut b = denomination_split(4_800, &sk, round_id, 1, van);
1579        deterministic_shuffle(&mut a, &sk, round_id, 1, van);
1580        deterministic_shuffle(&mut b, &sk, round_id, 1, van);
1581        assert_eq!(a, b, "same inputs must produce same permutation");
1582    }
1583
1584    #[test]
1585    fn shuffle_differs_across_proposals() {
1586        let sk = test_sk();
1587        let round_id = test_round_id();
1588        let van = test_van();
1589        let mut a = denomination_split(4_800, &sk, round_id, 1, van);
1590        let mut b = denomination_split(4_800, &sk, round_id, 1, van);
1591        deterministic_shuffle(&mut a, &sk, round_id, 1, van);
1592        deterministic_shuffle(&mut b, &sk, round_id, 2, van);
1593        assert_ne!(
1594            a, b,
1595            "different proposals should produce different permutations"
1596        );
1597    }
1598
1599    #[test]
1600    fn shuffle_differs_across_vans() {
1601        let sk = test_sk();
1602        let round_id = test_round_id();
1603        let van_a = pallas::Base::from(0xAAAA_u64);
1604        let van_b = pallas::Base::from(0xBBBB_u64);
1605        let mut a = denomination_split(4_800, &sk, round_id, 1, van_a);
1606        let mut b = denomination_split(4_800, &sk, round_id, 1, van_b);
1607        deterministic_shuffle(&mut a, &sk, round_id, 1, van_a);
1608        deterministic_shuffle(&mut b, &sk, round_id, 1, van_b);
1609        assert_ne!(a, b, "different VANs should produce different permutations");
1610    }
1611
1612    #[test]
1613    fn shuffle_actually_reorders() {
1614        let sk = test_sk();
1615        let round_id = test_round_id();
1616        let van = test_van();
1617        let original = denomination_split(4_800, &sk, round_id, 1, van);
1618        let mut shuffled = original;
1619        deterministic_shuffle(&mut shuffled, &sk, round_id, 1, van);
1620        assert_ne!(
1621            original, shuffled,
1622            "shuffle should reorder (vanishingly unlikely to be identity for 12 non-zero shares)"
1623        );
1624    }
1625
1626    #[test]
1627    fn prove_error_maps_into_build_error() {
1628        let err =
1629            VoteProofBuildError::from(ProveError::Halo2(halo2_proofs::plonk::Error::Synthesis));
1630
1631        assert!(matches!(err, VoteProofBuildError::Prove(_)));
1632    }
1633}