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