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::circuit::share_commitment` for the authoritative
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::circuit::share_commitment` is the source of
245    /// truth for the per-share preimage order, while `crate::shares_hash` owns
246    /// the aggregate preimage.
247    /// Provided as private witness inputs to the ZKP #3 builder. The reveal
248    /// circuit binds them transitively through `shares_hash`, so the helper
249    /// server only needs the primary share's blind, not all 16.
250    pub share_comms: [pallas::Base; 16],
251}
252
253/// Errors that can occur during vote proof construction.
254#[derive(Debug)]
255pub enum VoteProofBuildError {
256    /// A share randomness value could not be converted to a scalar.
257    InvalidRandomness(String),
258    /// The total note value cannot be split into valid shares.
259    InvalidShares(String),
260    /// The election authority's public key is the identity point.
261    InvalidElectionPublicKey,
262    /// The randomized voting public key is the identity point.
263    InvalidRandomizedVotingPublicKey,
264    /// A derived El Gamal ciphertext point was the identity point.
265    InvalidEncryptedShare(String),
266    /// The proposal identifier is outside the supported 1-indexed range.
267    InvalidProposalId(u64),
268    /// Halo2 proof creation failed.
269    Prove(ProveError),
270}
271
272impl From<ProveError> for VoteProofBuildError {
273    fn from(error: ProveError) -> Self {
274        VoteProofBuildError::Prove(error)
275    }
276}
277
278impl core::fmt::Display for VoteProofBuildError {
279    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
280        match self {
281            VoteProofBuildError::InvalidRandomness(msg) => {
282                write!(f, "invalid randomness: {}", msg)
283            }
284            VoteProofBuildError::InvalidShares(msg) => {
285                write!(f, "invalid shares: {}", msg)
286            }
287            VoteProofBuildError::InvalidElectionPublicKey => {
288                write!(f, "invalid election public key: identity point")
289            }
290            VoteProofBuildError::InvalidRandomizedVotingPublicKey => {
291                write!(f, "invalid randomized voting public key: identity point")
292            }
293            VoteProofBuildError::InvalidEncryptedShare(msg) => {
294                write!(f, "invalid encrypted share: {}", msg)
295            }
296            VoteProofBuildError::InvalidProposalId(proposal_id) => {
297                write!(
298                    f,
299                    "proposal_id must be in [1, {}], got {}",
300                    MAX_PROPOSAL_ID - 1,
301                    proposal_id
302                )
303            }
304            VoteProofBuildError::Prove(error) => {
305                write!(f, "proof generation failed: {error}")
306            }
307        }
308    }
309}
310
311impl std::error::Error for VoteProofBuildError {
312    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
313        match self {
314            VoteProofBuildError::Prove(error) => Some(error),
315            _ => None,
316        }
317    }
318}
319
320fn pallas_coordinates(point: pallas::Affine) -> Option<PallasAffineCoordinates> {
321    point.coordinates().into()
322}
323
324fn encrypted_share_coordinates(
325    point: pallas::Affine,
326    share_index: usize,
327    component: &'static str,
328) -> Result<PallasAffineCoordinates, VoteProofBuildError> {
329    pallas_coordinates(point).ok_or_else(|| {
330        VoteProofBuildError::InvalidEncryptedShare(format!(
331            "share {} {} is identity",
332            share_index, component
333        ))
334    })
335}
336
337/// Extract the voting spending key scalar from a SpendingKey.
338///
339/// This replicates the sign-correction logic from `SpendAuthorizingKey::from`:
340/// `ask = PRF_expand(sk)`, then negate if the resulting ak has ỹ = 1.
341fn extract_vsk(sk: &SpendingKey) -> pallas::Scalar {
342    let ask_raw = SpendAuthorizingKey::derive_inner(sk);
343    let g = pallas::Point::from(spend_auth_g_affine());
344    let ak_point = (g * ask_raw).to_affine();
345    let ak_bytes = ak_point.to_bytes();
346
347    // If the sign bit of ak is 1, the real ask was negated.
348    if (ak_bytes.as_ref()[31] >> 7) == 1 {
349        -ask_raw
350    } else {
351        ask_raw
352    }
353}
354
355/// Core PRF: BLAKE2b-512 bound to the spending key with voting-specific
356/// personalization and domain-separated inputs.
357///
358/// `PRF(sk, domain, round_id, proposal_id, van_commitment, share_index)`
359///   = BLAKE2b-512("ZcashVote_Expand", sk || domain || round_id || proposal_id_le64 || van_commitment || share_index_u8)
360///
361/// The `van_commitment` field binds the derivation to a specific VAN.
362/// Without it, a user with multiple VANs (from >5 notes in Phase 1)
363/// voting on the same proposal would derive identical El Gamal nonces,
364/// enabling a classic nonce-reuse attack on the ciphertexts.
365fn vote_share_prf(
366    sk: &SpendingKey,
367    domain: u8,
368    round_id: pallas::Base,
369    proposal_id: u64,
370    van_commitment: pallas::Base,
371    share_index: u8,
372) -> [u8; 64] {
373    *blake2b_simd::Params::new()
374        .hash_length(64)
375        .personal(domain_tags::VOTE_PRF_PERSONALIZATION)
376        .to_state()
377        .update(sk.to_bytes())
378        .update(&[domain])
379        .update(&round_id.to_repr())
380        .update(&proposal_id.to_le_bytes())
381        .update(&van_commitment.to_repr())
382        .update(&[share_index])
383        .finalize()
384        .as_array()
385}
386
387/// Derive deterministic El Gamal randomness `r_i` for a share.
388///
389/// Returns a non-zero `pallas::Base` element that is also a valid `pallas::Scalar`.
390/// We reduce mod p_base first; since p_base < q_scalar on the Pallas curve,
391/// every Base element is representable as a Scalar.
392fn derive_share_randomness(
393    sk: &SpendingKey,
394    round_id: pallas::Base,
395    proposal_id: u64,
396    van_commitment: pallas::Base,
397    share_index: u8,
398) -> pallas::Base {
399    let hash = vote_share_prf(
400        sk,
401        domain_tags::VOTE_PRF_DOMAIN_ELGAMAL,
402        round_id,
403        proposal_id,
404        van_commitment,
405        share_index,
406    );
407    let r = pallas::Base::from_uniform_bytes(&hash);
408    if bool::from(r.is_zero()) {
409        // Preserve deterministic derivation while satisfying the circuit's
410        // non-zero randomness gate in the negligible exact-zero case.
411        return pallas::Base::one();
412    }
413    debug_assert!(base_to_scalar(r).is_some(), "p < q guarantees Base→Scalar");
414    r
415}
416
417/// Derive deterministic blind factor `blind_i` for a share commitment.
418fn derive_share_blind(
419    sk: &SpendingKey,
420    round_id: pallas::Base,
421    proposal_id: u64,
422    van_commitment: pallas::Base,
423    share_index: u8,
424) -> pallas::Base {
425    let hash = vote_share_prf(
426        sk,
427        domain_tags::VOTE_PRF_DOMAIN_BLIND,
428        round_id,
429        proposal_id,
430        van_commitment,
431        share_index,
432    );
433    pallas::Base::from_uniform_bytes(&hash)
434}
435
436/// Deterministic Fisher-Yates shuffle of the shares array.
437///
438/// Prevents the sorted denomination order from leaking balance information
439/// through share indices. An adversary seeing (index, decrypted_value)
440/// would otherwise learn the denomination's rank in the sorted
441/// decomposition, tightening its estimate of the voter's total balance.
442/// Shuffling makes each index equally likely to hold any denomination.
443///
444/// The permutation is derived from the same PRF used for El Gamal randomness
445/// and blind factors, with a distinct shuffle domain separator.
446/// Share index 0 is used for the PRF call (the seed depends on the VAN, round,
447/// and proposal — not on the permutation step) to produce 64 bytes of
448/// pseudorandom data, which is consumed 4 bytes at a time for modular indices.
449fn deterministic_shuffle(
450    shares: &mut [u64; NUM_SHARES],
451    sk: &SpendingKey,
452    round_id: pallas::Base,
453    proposal_id: u64,
454    van_commitment: pallas::Base,
455) {
456    // The share index is hardcoded to 0 here because the shuffle
457    // function only needs one PRF call to seed the entire Fisher
458    // Yates shuffle. It doesn't need per-share derivations.
459    let seed = vote_share_prf(
460        sk,
461        domain_tags::VOTE_PRF_DOMAIN_SHUFFLE,
462        round_id,
463        proposal_id,
464        van_commitment,
465        0,
466    );
467    for i in (1..NUM_SHARES).rev() {
468        // Each iteration consumes the next 4-byte slice of the seed as a
469        // random u32: i=15 reads seed[0..4], i=14 reads seed[4..8], …,
470        // i=1 reads seed[56..60] (15 draws × 4 bytes = 60 of the 64-byte seed).
471        let byte_offset = (NUM_SHARES - 1 - i) * 4;
472        let rand_bytes: [u8; 4] = seed[byte_offset..byte_offset + 4]
473            .try_into()
474            .expect("64-byte seed has room for 15 × 4-byte draws");
475        let j = (u32::from_le_bytes(rand_bytes) as usize) % (i + 1);
476        shares.swap(i, j);
477    }
478}
479
480/// Build a real vote proof (ZKP #2) from delegation key material.
481///
482/// This function constructs the full vote proof circuit, computes all
483/// public inputs, and generates a Halo2 proof.
484///
485/// # Arguments
486///
487/// * `sk` - The SpendingKey used during delegation (ZKP #1).
488/// * `address_index` - The diversifier index of the output recipient
489///   address used in delegation (typically 1).
490/// * `total_note_value` - Sum of delegated note values in raw zatoshi (e.g. 15_000_000).
491///   Internally converted to ballot count via floor-division by BALLOT_DIVISOR.
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.
532#[allow(clippy::too_many_arguments)]
533pub fn build_vote_proof_from_delegation(
534    sk: &SpendingKey,
535    address_index: u32,
536    total_note_value: u64,
537    van_comm_rand: pallas::Base,
538    voting_round_id: pallas::Base,
539    vote_comm_tree_path: [pallas::Base; VOTE_COMM_TREE_DEPTH],
540    vote_comm_tree_position: u32,
541    anchor_height: u32,
542    proposal_id: u64,
543    vote_decision: u64,
544    ea_pk: pallas::Affine,
545    alpha_v: pallas::Scalar,
546    proposal_authority_old_u64: u64,
547    single_share: bool,
548) -> Result<VoteProofBundle, VoteProofBuildError> {
549    if proposal_id == 0 || proposal_id >= MAX_PROPOSAL_ID as u64 {
550        return Err(VoteProofBuildError::InvalidProposalId(proposal_id));
551    }
552
553    let ea_pk_coords =
554        pallas_coordinates(ea_pk).ok_or(VoteProofBuildError::InvalidElectionPublicKey)?;
555    let ea_pk_x = *ea_pk_coords.x();
556    let ea_pk_y = *ea_pk_coords.y();
557    let ea_pk_point = pallas::Point::from(ea_pk);
558
559    // ---- Key derivation (matches delegation's key hierarchy) ----
560
561    let vsk = extract_vsk(sk);
562    let fvk: FullViewingKey = sk.into();
563    let vsk_nk = fvk.nk().inner();
564    let rivk_v = fvk.rivk(Scope::External).inner();
565
566    let address = fvk.address_at(address_index, Scope::External);
567    let vpk_g_d_affine = address.g_d().to_affine();
568    let vpk_pk_d_affine = address.pk_d().inner().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 = (pallas::Point::from(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 = (pallas::Point::from(vpk_g_d_affine) * 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 = pallas::Point::from(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_point * 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 ----
765    // alpha_v is now provided by the caller so they can sign with rsk_v.
766    let ak_point = pallas::Point::from(spend_auth_g_affine()) * vsk;
767    let r_vpk = (ak_point + pallas::Point::from(spend_auth_g_affine()) * alpha_v).to_affine();
768    let r_vpk_coords =
769        pallas_coordinates(r_vpk).ok_or(VoteProofBuildError::InvalidRandomizedVotingPublicKey)?;
770    let r_vpk_x = *r_vpk_coords.x();
771    let r_vpk_y = *r_vpk_coords.y();
772    let r_vpk_bytes: [u8; 32] = r_vpk.to_bytes();
773
774    // ---- Vote commitment ----
775
776    let proposal_id_base = pallas::Base::from(proposal_id);
777    let vote_decision_base = pallas::Base::from(vote_decision);
778    let vote_commitment = vote_commitment_hash(
779        voting_round_id,
780        shares_hash_val,
781        proposal_id_base,
782        vote_decision_base,
783    );
784
785    // ---- Vote commitment tree root (from auth path) ----
786    // Recompute the root from the leaf + auth path to set as public input.
787
788    let vote_comm_tree_root = {
789        use super::circuit::poseidon_hash_2;
790
791        let mut current = vote_authority_note_old;
792        for level in 0..VOTE_COMM_TREE_DEPTH {
793            let sibling = vote_comm_tree_path[level];
794            if vote_comm_tree_position & (1 << level) == 0 {
795                current = poseidon_hash_2(current, sibling);
796            } else {
797                current = poseidon_hash_2(sibling, current);
798            }
799        }
800        current
801    };
802
803    // ---- Build circuit ----
804
805    let mut circuit = Circuit::with_van_witnesses(
806        Value::known(vote_comm_tree_path),
807        Value::known(vote_comm_tree_position),
808        Value::known(vpk_g_d_affine),
809        Value::known(vpk_pk_d_affine),
810        Value::known(num_ballots_base),
811        Value::known(proposal_authority_old),
812        Value::known(van_comm_rand),
813        Value::known(vote_authority_note_old),
814        Value::known(vsk),
815        Value::known(rivk_v),
816        Value::known(vsk_nk),
817        Value::known(alpha_v),
818    );
819    circuit.one_shifted = Value::known(one_shifted);
820    circuit.shares = shares_base.map(Value::known);
821    circuit.enc_share_c1_x = enc_c1_x.map(Value::known);
822    circuit.enc_share_c2_x = enc_c2_x.map(Value::known);
823    circuit.enc_share_c1_y = enc_c1_y.map(Value::known);
824    circuit.enc_share_c2_y = enc_c2_y.map(Value::known);
825    circuit.share_blinds = share_blinds.map(Value::known);
826    circuit.share_randomness = share_randomness.map(Value::known);
827    circuit.ea_pk = Value::known(ea_pk);
828    circuit.vote_decision = Value::known(vote_decision_base);
829
830    // ---- Build instance (public inputs) ----
831
832    let anchor_height_base = pallas::Base::from(u64::from(anchor_height));
833    let instance = Instance::from_parts(
834        van_nullifier,
835        r_vpk_x,
836        r_vpk_y,
837        vote_authority_note_new,
838        vote_commitment,
839        vote_comm_tree_root,
840        anchor_height_base,
841        proposal_id_base,
842        voting_round_id,
843        ea_pk_x,
844        ea_pk_y,
845    );
846
847    // ---- Generate proof ----
848
849    let proof = create_vote_proof(circuit, &instance)?;
850
851    Ok(VoteProofBundle {
852        proof,
853        instance,
854        r_vpk_bytes,
855        encrypted_shares: enc_share_outputs,
856        shares_hash: shares_hash_val,
857        share_blinds,
858        share_comms,
859    })
860}
861
862#[cfg(test)]
863mod tests {
864    use super::*;
865    use ff::Field;
866    use group::Group;
867
868    fn test_sk() -> SpendingKey {
869        SpendingKey::from_bytes([0x42; 32]).expect("valid spending key")
870    }
871
872    fn test_round_id() -> pallas::Base {
873        pallas::Base::from(0xCAFE_u64)
874    }
875
876    fn test_van() -> pallas::Base {
877        pallas::Base::from(0xDEAD_u64)
878    }
879
880    #[test]
881    fn vote_share_prf_has_frozen_test_vector() {
882        let hash = vote_share_prf(
883            &test_sk(),
884            crate::domain_tags::VOTE_PRF_DOMAIN_ELGAMAL,
885            test_round_id(),
886            1,
887            test_van(),
888            0,
889        );
890
891        assert_eq!(
892            hash,
893            [
894                0x62, 0x03, 0x29, 0x9b, 0x2c, 0x58, 0x4b, 0xa6, 0x37, 0x4d, 0xbe, 0xd6, 0x45, 0x71,
895                0x6f, 0x03, 0x31, 0x56, 0x95, 0x6f, 0xf1, 0x88, 0x8e, 0x75, 0x41, 0x43, 0xb1, 0xf5,
896                0x54, 0xea, 0xb5, 0xb0, 0x6b, 0xdf, 0x7d, 0xca, 0xd4, 0x5a, 0xc2, 0xf4, 0xb9, 0x6a,
897                0xe4, 0x5b, 0xb9, 0x98, 0xd0, 0x5b, 0x4a, 0x8f, 0x12, 0x49, 0x52, 0xb3, 0x0b, 0x19,
898                0xc1, 0xaf, 0x89, 0x35, 0x8a, 0x96, 0xe0, 0x2c,
899            ]
900        );
901    }
902
903    #[test]
904    fn build_vote_proof_rejects_invalid_proposal_id() {
905        let sk = test_sk();
906
907        for proposal_id in [0, MAX_PROPOSAL_ID as u64, 64] {
908            let err = build_vote_proof_from_delegation(
909                &sk,
910                1,
911                BALLOT_DIVISOR,
912                test_van(),
913                test_round_id(),
914                [pallas::Base::from(0u64); VOTE_COMM_TREE_DEPTH],
915                0,
916                123,
917                proposal_id,
918                1,
919                pallas::Point::identity().to_affine(),
920                pallas::Scalar::from(7u64),
921                65535,
922                true,
923            )
924            .expect_err("invalid proposal_id should be rejected before proof generation");
925
926            assert!(matches!(
927                err,
928                VoteProofBuildError::InvalidProposalId(rejected) if rejected == proposal_id
929            ));
930        }
931    }
932
933    #[test]
934    fn build_vote_proof_rejects_identity_ea_pk() {
935        let sk = test_sk();
936        let err = build_vote_proof_from_delegation(
937            &sk,
938            1,
939            BALLOT_DIVISOR,
940            test_van(),
941            test_round_id(),
942            [pallas::Base::from(0u64); VOTE_COMM_TREE_DEPTH],
943            0,
944            123,
945            1,
946            1,
947            pallas::Point::identity().to_affine(),
948            pallas::Scalar::from(7u64),
949            65535,
950            true,
951        )
952        .expect_err("identity ea_pk should be rejected before proof generation");
953
954        assert!(matches!(err, VoteProofBuildError::InvalidElectionPublicKey));
955    }
956
957    #[test]
958    fn encrypted_share_coordinates_rejects_identity_c1_point() {
959        let err = encrypted_share_coordinates(pallas::Point::identity().to_affine(), 7, "c1")
960            .expect_err("identity c1 point should be rejected");
961
962        assert!(matches!(
963            err,
964            VoteProofBuildError::InvalidEncryptedShare(msg)
965                if msg == "share 7 c1 is identity"
966        ));
967    }
968
969    #[test]
970    fn build_vote_proof_rejects_identity_c2_point() {
971        let sk = test_sk();
972        let voting_round_id = test_round_id();
973        let proposal_id = 1;
974        let proposal_authority_old_u64 = 65535;
975        let van_comm_rand = test_van();
976        let num_ballots_base = pallas::Base::from(1u64);
977
978        let fvk: FullViewingKey = (&sk).into();
979        let address = fvk.address_at(1u32, Scope::External);
980        let vpk_g_d_affine = address.g_d().to_affine();
981        let vpk_pk_d_affine = address.pk_d().inner().to_affine();
982        let vpk_g_d_x = *vpk_g_d_affine.coordinates().unwrap().x();
983        let vpk_pk_d_x = *vpk_pk_d_affine.coordinates().unwrap().x();
984
985        let vote_authority_note_old = van_integrity_hash(
986            vpk_g_d_x,
987            vpk_pk_d_x,
988            num_ballots_base,
989            voting_round_id,
990            pallas::Base::from(proposal_authority_old_u64),
991            van_comm_rand,
992        );
993        let r = derive_share_randomness(
994            &sk,
995            voting_round_id,
996            proposal_id,
997            vote_authority_note_old,
998            0,
999        );
1000        let r_scalar = base_to_scalar(r).expect("test randomness should be scalar-range");
1001        let r_inv: Option<pallas::Scalar> = r_scalar.invert().into();
1002        let ea_pk_scalar =
1003            -pallas::Scalar::from(1u64) * r_inv.expect("test randomness should be non-zero");
1004        let ea_pk = (pallas::Point::from(spend_auth_g_affine()) * ea_pk_scalar).to_affine();
1005
1006        let err = build_vote_proof_from_delegation(
1007            &sk,
1008            1,
1009            BALLOT_DIVISOR,
1010            van_comm_rand,
1011            voting_round_id,
1012            [pallas::Base::from(0u64); VOTE_COMM_TREE_DEPTH],
1013            0,
1014            123,
1015            proposal_id,
1016            1,
1017            ea_pk,
1018            pallas::Scalar::from(7u64),
1019            proposal_authority_old_u64,
1020            true,
1021        )
1022        .expect_err("crafted ea_pk should make share 0 c2 the identity");
1023
1024        assert!(matches!(
1025            err,
1026            VoteProofBuildError::InvalidEncryptedShare(msg)
1027                if msg == "share 0 c2 is identity"
1028        ));
1029    }
1030
1031    #[test]
1032    fn build_vote_proof_rejects_identity_r_vpk() {
1033        let sk = test_sk();
1034        let ea_pk =
1035            (pallas::Point::from(spend_auth_g_affine()) * pallas::Scalar::from(42u64)).to_affine();
1036        let err = build_vote_proof_from_delegation(
1037            &sk,
1038            1,
1039            BALLOT_DIVISOR,
1040            test_van(),
1041            test_round_id(),
1042            [pallas::Base::from(0u64); VOTE_COMM_TREE_DEPTH],
1043            0,
1044            123,
1045            1,
1046            1,
1047            ea_pk,
1048            -extract_vsk(&sk),
1049            65535,
1050            true,
1051        )
1052        .expect_err("alpha_v = -vsk should make r_vpk the identity");
1053
1054        assert!(matches!(
1055            err,
1056            VoteProofBuildError::InvalidRandomizedVotingPublicKey
1057        ));
1058    }
1059
1060    #[test]
1061    fn derive_share_randomness_is_deterministic() {
1062        let sk = test_sk();
1063        let round_id = test_round_id();
1064        let van = test_van();
1065        let a = derive_share_randomness(&sk, round_id, 1, van, 0);
1066        let b = derive_share_randomness(&sk, round_id, 1, van, 0);
1067        assert_eq!(a, b);
1068    }
1069
1070    #[test]
1071    fn derive_share_blind_is_deterministic() {
1072        let sk = test_sk();
1073        let round_id = test_round_id();
1074        let van = test_van();
1075        let a = derive_share_blind(&sk, round_id, 1, van, 0);
1076        let b = derive_share_blind(&sk, round_id, 1, van, 0);
1077        assert_eq!(a, b);
1078    }
1079
1080    #[test]
1081    fn derive_share_randomness_is_nonzero_valid_scalar() {
1082        let sk = test_sk();
1083        let round_id = test_round_id();
1084        let van = test_van();
1085        for i in 0..16u8 {
1086            let r = derive_share_randomness(&sk, round_id, 1, van, i);
1087            assert!(
1088                bool::from(!r.is_zero()),
1089                "r_{} must be non-zero for the circuit hardening gate",
1090                i
1091            );
1092            assert!(
1093                base_to_scalar(r).is_some(),
1094                "r_{} must be convertible to scalar",
1095                i
1096            );
1097        }
1098    }
1099
1100    #[test]
1101    fn different_share_index_gives_different_values() {
1102        let sk = test_sk();
1103        let round_id = test_round_id();
1104        let van = test_van();
1105        let r0 = derive_share_randomness(&sk, round_id, 1, van, 0);
1106        let r1 = derive_share_randomness(&sk, round_id, 1, van, 1);
1107        assert_ne!(r0, r1);
1108
1109        let b0 = derive_share_blind(&sk, round_id, 1, van, 0);
1110        let b1 = derive_share_blind(&sk, round_id, 1, van, 1);
1111        assert_ne!(b0, b1);
1112    }
1113
1114    #[test]
1115    fn different_proposal_id_gives_different_values() {
1116        let sk = test_sk();
1117        let round_id = test_round_id();
1118        let van = test_van();
1119        let r_p1 = derive_share_randomness(&sk, round_id, 1, van, 0);
1120        let r_p2 = derive_share_randomness(&sk, round_id, 2, van, 0);
1121        assert_ne!(r_p1, r_p2);
1122    }
1123
1124    #[test]
1125    fn different_round_id_gives_different_values() {
1126        let sk = test_sk();
1127        let van = test_van();
1128        let r_a = derive_share_randomness(&sk, pallas::Base::from(1u64), 1, van, 0);
1129        let r_b = derive_share_randomness(&sk, pallas::Base::from(2u64), 1, van, 0);
1130        assert_ne!(r_a, r_b);
1131    }
1132
1133    #[test]
1134    fn randomness_and_blind_differ_for_same_inputs() {
1135        let sk = test_sk();
1136        let round_id = test_round_id();
1137        let van = test_van();
1138        let r = derive_share_randomness(&sk, round_id, 1, van, 0);
1139        let b = derive_share_blind(&sk, round_id, 1, van, 0);
1140        assert_ne!(r, b, "domain separation must prevent r == blind");
1141    }
1142
1143    #[test]
1144    fn all_16_shares_are_distinct() {
1145        let sk = test_sk();
1146        let round_id = test_round_id();
1147        let van = test_van();
1148        let randoms: Vec<_> = (0..16u8)
1149            .map(|i| derive_share_randomness(&sk, round_id, 1, van, i))
1150            .collect();
1151        let blinds: Vec<_> = (0..16u8)
1152            .map(|i| derive_share_blind(&sk, round_id, 1, van, i))
1153            .collect();
1154        for i in 0..16 {
1155            for j in (i + 1)..16 {
1156                assert_ne!(randoms[i], randoms[j], "r_{} == r_{}", i, j);
1157                assert_ne!(blinds[i], blinds[j], "blind_{} == blind_{}", i, j);
1158            }
1159        }
1160    }
1161
1162    #[test]
1163    fn different_van_commitment_gives_different_values() {
1164        let sk = test_sk();
1165        let round_id = test_round_id();
1166        let van_a = pallas::Base::from(0xAAAA_u64);
1167        let van_b = pallas::Base::from(0xBBBB_u64);
1168        for i in 0..16u8 {
1169            let r_a = derive_share_randomness(&sk, round_id, 1, van_a, i);
1170            let r_b = derive_share_randomness(&sk, round_id, 1, van_b, i);
1171            assert_ne!(r_a, r_b, "r_{} must differ across VANs", i);
1172
1173            let b_a = derive_share_blind(&sk, round_id, 1, van_a, i);
1174            let b_b = derive_share_blind(&sk, round_id, 1, van_b, i);
1175            assert_ne!(b_a, b_b, "blind_{} must differ across VANs", i);
1176        }
1177    }
1178
1179    // ---- denomination_split tests ----
1180    //
1181    // Visual key:
1182    //   D = denomination (standard value, blends across voters)
1183    //   R = random (PRF-derived, prevents exact balance fingerprint)
1184    //   0 = zero (encrypted with fresh randomness, indistinguishable from non-zero)
1185    //
1186    // Layout: [0..8] = greedy denom slots | [9..15] = remainder / random slots
1187    // After shuffle, positions are randomized — these show the pre-shuffle array.
1188
1189    /// Helper: print shares array for visual inspection during --nocapture runs.
1190    fn show(label: &str, shares: &[u64; 16]) {
1191        let parts: Vec<String> = shares
1192            .iter()
1193            .map(|&v| {
1194                if v == 0 {
1195                    "0".into()
1196                } else if v >= 1_000_000 {
1197                    format!("{}M", v / 1_000_000)
1198                } else if v >= 1_000 {
1199                    format!("{}K", v / 1_000)
1200                } else {
1201                    format!("{}", v)
1202                }
1203            })
1204            .collect();
1205        std::eprintln!("  {}: [{}]", label, parts.join(", "));
1206    }
1207
1208    #[test]
1209    fn denom_split_zero_ballots() {
1210        // 0 ballots — all slots empty
1211        // [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
1212        let sk = test_sk();
1213        let rid = test_round_id();
1214        let van = test_van();
1215        let shares = denomination_split(0, &sk, rid, 1, van);
1216        show("0 ballots", &shares);
1217        assert_eq!(shares, [0; 16]);
1218    }
1219
1220    #[test]
1221    fn denom_split_single_ballot() {
1222        // 1 ballot (0.125 ZEC) — smallest denomination
1223        // [D:1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
1224        let sk = test_sk();
1225        let rid = test_round_id();
1226        let van = test_van();
1227        let shares = denomination_split(1, &sk, rid, 1, van);
1228        show("1 ballot (0.125 ZEC)", &shares);
1229        assert_eq!(shares[0], 1);
1230        for i in 1..16 {
1231            assert_eq!(shares[i], 0);
1232        }
1233    }
1234
1235    #[test]
1236    fn denom_split_sub_zec() {
1237        // 4 ballots (0.5 ZEC)
1238        // [D:1, D:1, D:1, D:1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
1239        let sk = test_sk();
1240        let rid = test_round_id();
1241        let van = test_van();
1242        let shares = denomination_split(4, &sk, rid, 1, van);
1243        show("4 ballots (0.5 ZEC)", &shares);
1244        assert_eq!(shares[0..4], [1; 4]);
1245        for i in 4..16 {
1246            assert_eq!(shares[i], 0);
1247        }
1248    }
1249
1250    #[test]
1251    fn denom_split_one_zec() {
1252        // 8 ballots (1 ZEC)
1253        // [D:1, D:1, D:1, D:1, D:1, D:1, D:1, D:1, 0, 0, 0, 0, 0, 0, 0, 0]
1254        let sk = test_sk();
1255        let rid = test_round_id();
1256        let van = test_van();
1257        let shares = denomination_split(8, &sk, rid, 1, van);
1258        show("8 ballots (1 ZEC)", &shares);
1259        assert_eq!(shares[0..8], [1; 8]);
1260        for i in 8..16 {
1261            assert_eq!(shares[i], 0);
1262        }
1263    }
1264
1265    #[test]
1266    fn denom_split_small_balance() {
1267        // 50 ballots (6.25 ZEC) — 5 denom slots, all standard
1268        // [D:10, D:10, D:10, D:10, D:10, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
1269        let sk = test_sk();
1270        let rid = test_round_id();
1271        let van = test_van();
1272        let shares = denomination_split(50, &sk, rid, 1, van);
1273        show("50 ballots (6.25 ZEC)", &shares);
1274        assert_eq!(shares[0..5], [10; 5]);
1275        for i in 5..16 {
1276            assert_eq!(shares[i], 0);
1277        }
1278    }
1279
1280    #[test]
1281    fn denom_split_all_denoms_exact() {
1282        // 11,111 ballots (1,388.9 ZEC) — one of each denom, no remainder
1283        // [D:10K, D:1K, D:100, D:10, D:1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
1284        let sk = test_sk();
1285        let rid = test_round_id();
1286        let van = test_van();
1287        let shares = denomination_split(11_111, &sk, rid, 1, van);
1288        show("11,111 ballots (1,388.9 ZEC)", &shares);
1289        assert_eq!(shares[0], 10_000);
1290        assert_eq!(shares[1], 1_000);
1291        assert_eq!(shares[2], 100);
1292        assert_eq!(shares[3], 10);
1293        assert_eq!(shares[4], 1);
1294        for i in 5..16 {
1295            assert_eq!(shares[i], 0);
1296        }
1297    }
1298
1299    #[test]
1300    fn denom_split_medium_holder_with_remainder() {
1301        // 4,800 ballots (600 ZEC) — greedy fills 9 (4×1K + 5×100 = 4,500), remainder 300
1302        // [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]
1303        //  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^  ^^^^^^^^^^^^^^^^^^^^
1304        //  9 denomination slots (4,500)                                  7 random slots (300)
1305        let sk = test_sk();
1306        let rid = test_round_id();
1307        let van = test_van();
1308        let shares = denomination_split(4_800, &sk, rid, 1, van);
1309        show("4,800 ballots (600 ZEC)", &shares);
1310        assert_eq!(shares[0..4], [1_000; 4]);
1311        assert_eq!(shares[4..9], [100; 5]);
1312        let remainder_sum: u64 = shares[9..16].iter().sum();
1313        assert_eq!(remainder_sum, 300);
1314        for i in 9..16 {
1315            assert!(shares[i] > 0, "remainder slot {} should be non-zero", i);
1316        }
1317        assert_eq!(shares.iter().sum::<u64>(), 4_800);
1318    }
1319
1320    #[test]
1321    fn denom_split_high_hamming_weight() {
1322        // 999 ballots (124.875 ZEC) — greedy fills 9 (9×100 = 900), remainder 99
1323        // [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]
1324        //  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^   ^^^^^^^^^^^^^^^^^^^^
1325        //  9 denomination slots (900)                                       7 random slots (99)
1326        let sk = test_sk();
1327        let rid = test_round_id();
1328        let van = test_van();
1329        let shares = denomination_split(999, &sk, rid, 1, van);
1330        show("999 ballots (124.875 ZEC)", &shares);
1331        assert_eq!(shares[0..9], [100; 9]);
1332        let remainder_sum: u64 = shares[9..16].iter().sum();
1333        assert_eq!(remainder_sum, 99);
1334        for i in 9..16 {
1335            assert!(shares[i] > 0, "remainder slot {} should be non-zero", i);
1336        }
1337    }
1338
1339    #[test]
1340    fn denom_split_exact_denomination_match() {
1341        // 3M ballots (375 ZEC) — 3 denom slots, no remainder
1342        // [D:1M, D:1M, D:1M, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
1343        let sk = test_sk();
1344        let rid = test_round_id();
1345        let van = test_van();
1346        let shares = denomination_split(3_000_000, &sk, rid, 1, van);
1347        show("3M ballots (375 ZEC)", &shares);
1348        assert_eq!(shares[0..3], [1_000_000; 3]);
1349        for i in 3..16 {
1350            assert_eq!(shares[i], 0);
1351        }
1352    }
1353
1354    #[test]
1355    fn denom_split_8m_ballots() {
1356        // 8M ballots (1M ZEC) — 8 denom slots, no remainder
1357        // [D:1M, D:1M, D:1M, D:1M, D:1M, D:1M, D:1M, D:1M, 0, 0, 0, 0, 0, 0, 0, 0]
1358        let sk = test_sk();
1359        let rid = test_round_id();
1360        let van = test_van();
1361        let shares = denomination_split(8_000_000, &sk, rid, 1, van);
1362        show("8M ballots (1M ZEC)", &shares);
1363        assert_eq!(shares[0..8], [1_000_000; 8]);
1364        for i in 8..16 {
1365            assert_eq!(shares[i], 0);
1366        }
1367    }
1368
1369    #[test]
1370    fn denom_split_fills_all_9_denom_slots() {
1371        // 90M ballots (11.25M ZEC) — all 9 denom slots filled, no remainder
1372        // [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]
1373        let sk = test_sk();
1374        let rid = test_round_id();
1375        let van = test_van();
1376        let shares = denomination_split(90_000_000, &sk, rid, 1, van);
1377        show("90M ballots (11.25M ZEC)", &shares);
1378        assert_eq!(shares[0..9], [10_000_000; 9]);
1379        for i in 9..16 {
1380            assert_eq!(shares[i], 0);
1381        }
1382    }
1383
1384    #[test]
1385    fn denom_split_overflow_into_remainder() {
1386        // 100M ballots (12.5M ZEC) — 9 denom slots full (9×10M), remainder 10M in 7 random slots
1387        // [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]
1388        //  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^    ^^^^^^^^^^^^^^^^^^^^
1389        //  9 denomination slots (90M)                                        7 random slots (10M)
1390        let sk = test_sk();
1391        let rid = test_round_id();
1392        let van = test_van();
1393        let shares = denomination_split(100_000_000, &sk, rid, 1, van);
1394        show("100M ballots (12.5M ZEC)", &shares);
1395        assert_eq!(shares[0..9], [10_000_000; 9]);
1396        let remainder_sum: u64 = shares[9..16].iter().sum();
1397        assert_eq!(remainder_sum, 10_000_000);
1398        for i in 9..16 {
1399            assert!(shares[i] > 0, "remainder slot {} should be non-zero", i);
1400        }
1401    }
1402
1403    #[test]
1404    fn denom_split_mixed_with_remainder() {
1405        // 1,234,567 ballots (154,320.9 ZEC) — 9 denom slots, remainder distributed
1406        // [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]
1407        //  greedy: 1M + 200K + 30K + 3K = 1,233,000                      remainder: 1,567
1408        let sk = test_sk();
1409        let rid = test_round_id();
1410        let van = test_van();
1411        let shares = denomination_split(1_234_567, &sk, rid, 1, van);
1412        show("1,234,567 ballots (154K ZEC)", &shares);
1413        assert_eq!(shares[0], 1_000_000);
1414        assert_eq!(shares[1..3], [100_000; 2]);
1415        assert_eq!(shares[3..6], [10_000; 3]);
1416        assert_eq!(shares[6..9], [1_000; 3]);
1417        let remainder_sum: u64 = shares[9..16].iter().sum();
1418        assert_eq!(remainder_sum, 1_567);
1419        assert_eq!(shares.iter().sum::<u64>(), 1_234_567);
1420    }
1421
1422    #[test]
1423    fn denom_split_small_remainder_fewer_than_free_slots() {
1424        // 10,000,003 ballots — 1 denom slot (10M), remainder 3 across 7 free slots
1425        // [D:10M, R:1, R:1, R:1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
1426        //  remainder 3 < 7 free slots, so only 3 of 7 get a value
1427        let sk = test_sk();
1428        let rid = test_round_id();
1429        let van = test_van();
1430        let shares = denomination_split(10_000_003, &sk, rid, 1, van);
1431        show("10,000,003 ballots", &shares);
1432        assert_eq!(shares[0], 10_000_000);
1433        let remainder_sum: u64 = shares[1..16].iter().sum();
1434        assert_eq!(remainder_sum, 3);
1435        assert_eq!(shares.iter().sum::<u64>(), 10_000_003);
1436    }
1437
1438    // ---- invariant tests ----
1439
1440    #[test]
1441    fn denom_split_sum_invariant() {
1442        let sk = test_sk();
1443        let rid = test_round_id();
1444        let van = test_van();
1445        let test_values: [u64; 14] = [
1446            0,
1447            1,
1448            50,
1449            99,
1450            100,
1451            999,
1452            1_000,
1453            10_000,
1454            100_000,
1455            1_000_000,
1456            8_234_567,
1457            20_000_000,
1458            80_000_000,
1459            168_000_000,
1460        ];
1461        for &v in &test_values {
1462            let shares = denomination_split(v, &sk, rid, 1, van);
1463            assert_eq!(
1464                shares.iter().sum::<u64>(),
1465                v,
1466                "sum invariant violated for {}",
1467                v
1468            );
1469        }
1470    }
1471
1472    #[test]
1473    fn denom_split_all_shares_in_range() {
1474        let sk = test_sk();
1475        let rid = test_round_id();
1476        let van = test_van();
1477        let test_values: [u64; 8] = [
1478            1,
1479            10_000,
1480            1_000_000,
1481            8_234_567,
1482            15_000_000,
1483            20_000_000,
1484            80_000_000,
1485            168_000_000,
1486        ];
1487        for &v in &test_values {
1488            let shares = denomination_split(v, &sk, rid, 1, van);
1489            for (i, &s) in shares.iter().enumerate() {
1490                assert!(
1491                    s < (1u64 << 30),
1492                    "share {} = {} exceeds 2^30 for {}",
1493                    i,
1494                    s,
1495                    v
1496                );
1497            }
1498        }
1499    }
1500
1501    // ---- remainder randomization tests ----
1502
1503    #[test]
1504    fn remainder_is_deterministic() {
1505        let sk = test_sk();
1506        let rid = test_round_id();
1507        let van = test_van();
1508        let a = denomination_split(999, &sk, rid, 1, van);
1509        let b = denomination_split(999, &sk, rid, 1, van);
1510        assert_eq!(a, b);
1511    }
1512
1513    #[test]
1514    fn remainder_differs_across_proposals() {
1515        // Same balance, different proposal_id → same denoms, different random remainder
1516        let sk = test_sk();
1517        let rid = test_round_id();
1518        let van = test_van();
1519        let a = denomination_split(999, &sk, rid, 1, van);
1520        let b = denomination_split(999, &sk, rid, 2, van);
1521        show("999 ballots, proposal 1", &a);
1522        show("999 ballots, proposal 2", &b);
1523        assert_eq!(a[0..9], b[0..9], "denomination slots should be identical");
1524        assert_ne!(
1525            a[9..16],
1526            b[9..16],
1527            "remainder should differ across proposals"
1528        );
1529    }
1530
1531    #[test]
1532    fn remainder_differs_across_vans() {
1533        // Same balance, different VAN → same denoms, different random remainder
1534        let sk = test_sk();
1535        let rid = test_round_id();
1536        let van_a = pallas::Base::from(0xAAAA_u64);
1537        let van_b = pallas::Base::from(0xBBBB_u64);
1538        let a = denomination_split(999, &sk, rid, 1, van_a);
1539        let b = denomination_split(999, &sk, rid, 1, van_b);
1540        show("999 ballots, VAN A", &a);
1541        show("999 ballots, VAN B", &b);
1542        assert_eq!(a[0..9], b[0..9], "denomination slots should be identical");
1543        assert_ne!(a[9..16], b[9..16], "remainder should differ across VANs");
1544    }
1545
1546    // ---- deterministic_shuffle tests ----
1547
1548    #[test]
1549    fn shuffle_preserves_sum() {
1550        let sk = test_sk();
1551        let round_id = test_round_id();
1552        let van = test_van();
1553        let mut shares = denomination_split(8_234_567, &sk, round_id, 1, van);
1554        let sum_before = shares.iter().sum::<u64>();
1555        deterministic_shuffle(&mut shares, &sk, round_id, 1, van);
1556        assert_eq!(shares.iter().sum::<u64>(), sum_before);
1557    }
1558
1559    #[test]
1560    fn shuffle_preserves_multiset() {
1561        let sk = test_sk();
1562        let round_id = test_round_id();
1563        let van = test_van();
1564        let original = denomination_split(4_800, &sk, round_id, 1, van);
1565        let mut shuffled = original;
1566        deterministic_shuffle(&mut shuffled, &sk, round_id, 1, van);
1567        let mut sorted_orig = original;
1568        sorted_orig.sort();
1569        let mut sorted_shuf = shuffled;
1570        sorted_shuf.sort();
1571        assert_eq!(sorted_orig, sorted_shuf, "shuffle must be a permutation");
1572    }
1573
1574    #[test]
1575    fn shuffle_is_deterministic() {
1576        let sk = test_sk();
1577        let round_id = test_round_id();
1578        let van = test_van();
1579        let mut a = denomination_split(4_800, &sk, round_id, 1, van);
1580        let mut b = denomination_split(4_800, &sk, round_id, 1, van);
1581        deterministic_shuffle(&mut a, &sk, round_id, 1, van);
1582        deterministic_shuffle(&mut b, &sk, round_id, 1, van);
1583        assert_eq!(a, b, "same inputs must produce same permutation");
1584    }
1585
1586    #[test]
1587    fn shuffle_differs_across_proposals() {
1588        let sk = test_sk();
1589        let round_id = test_round_id();
1590        let van = test_van();
1591        let mut a = denomination_split(4_800, &sk, round_id, 1, van);
1592        let mut b = denomination_split(4_800, &sk, round_id, 1, van);
1593        deterministic_shuffle(&mut a, &sk, round_id, 1, van);
1594        deterministic_shuffle(&mut b, &sk, round_id, 2, van);
1595        assert_ne!(
1596            a, b,
1597            "different proposals should produce different permutations"
1598        );
1599    }
1600
1601    #[test]
1602    fn shuffle_differs_across_vans() {
1603        let sk = test_sk();
1604        let round_id = test_round_id();
1605        let van_a = pallas::Base::from(0xAAAA_u64);
1606        let van_b = pallas::Base::from(0xBBBB_u64);
1607        let mut a = denomination_split(4_800, &sk, round_id, 1, van_a);
1608        let mut b = denomination_split(4_800, &sk, round_id, 1, van_b);
1609        deterministic_shuffle(&mut a, &sk, round_id, 1, van_a);
1610        deterministic_shuffle(&mut b, &sk, round_id, 1, van_b);
1611        assert_ne!(a, b, "different VANs should produce different permutations");
1612    }
1613
1614    #[test]
1615    fn shuffle_actually_reorders() {
1616        let sk = test_sk();
1617        let round_id = test_round_id();
1618        let van = test_van();
1619        let original = denomination_split(4_800, &sk, round_id, 1, van);
1620        let mut shuffled = original;
1621        deterministic_shuffle(&mut shuffled, &sk, round_id, 1, van);
1622        assert_ne!(
1623            original, shuffled,
1624            "shuffle should reorder (vanishingly unlikely to be identity for 12 non-zero shares)"
1625        );
1626    }
1627
1628    #[test]
1629    fn prove_error_maps_into_build_error() {
1630        let err =
1631            VoteProofBuildError::from(ProveError::Halo2(halo2_proofs::plonk::Error::Synthesis));
1632
1633        assert!(matches!(err, VoteProofBuildError::Prove(_)));
1634    }
1635}