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