Skip to main content

voting_circuits/delegation/
builder.rs

1//! Multi-note delegation bundle builder.
2//!
3//! Orchestrates the creation of a complete delegation proof:
4//! a single merged circuit proving all 15 conditions for up to 5 notes.
5//! Handles padding unused note slots with zero-value notes that still carry
6//! valid IMT non-membership proofs against the real tree root.
7
8use group::Curve;
9use halo2_proofs::circuit::Value;
10use pasta_curves::{arithmetic::CurveAffine, pallas};
11use rand::RngCore;
12use std::vec::Vec;
13
14use orchard::{
15    keys::{FullViewingKey, Scope, SpendValidatingKey},
16    note::{commitment::ExtractedNoteCommitment, nullifier::Nullifier, Note, RandomSeed, Rho},
17    spec::NonIdentityPallasPoint,
18    tree::MerklePath,
19    value::NoteValue,
20};
21
22use super::{
23    circuit::{self, rho_binding_hash, van_commitment_hash, NoteSlotWitness},
24    imt::{derive_nullifier_domain, gov_null_hash, ImtProofData, ImtProvider},
25};
26
27/// Rho and rseed for a single padded note, captured during Phase 1 (PCZT construction).
28#[derive(Clone, Debug)]
29pub struct PaddedNoteData {
30    /// Rho bytes (32 bytes, LE encoding of pallas::Base).
31    pub rho: [u8; 32],
32    /// Random seed bytes (32 bytes).
33    pub rseed: [u8; 32],
34}
35
36/// Randomness captured during Phase 1 (PCZT construction) that must be reused
37/// in Phase 2 (ZK proving) so the prover commits to the same nf_signed/cmx_new
38/// that the signer committed to via the ZIP-244 sighash.
39#[derive(Clone, Debug)]
40pub struct PrecomputedRandomness {
41    /// Rho + rseed for each padded note (0–3 entries).
42    pub padded_notes: Vec<PaddedNoteData>,
43    /// Rseed for the signed (keystone) note.
44    pub rseed_signed: [u8; 32],
45    /// Rseed for the output note.
46    pub rseed_output: [u8; 32],
47}
48
49/// Input for a single real note in the delegation.
50#[derive(Debug)]
51pub struct RealNoteInput {
52    /// The note being delegated.
53    pub note: Note,
54    /// The note's full viewing key.
55    pub fvk: FullViewingKey,
56    /// Merkle authentication path for the note commitment.
57    pub merkle_path: MerklePath,
58    /// IMT non-membership proof for this note's nullifier.
59    pub imt_proof: ImtProofData,
60    /// Whether this note uses the internal (change) or external scope.
61    pub scope: Scope,
62}
63
64/// Complete delegation bundle: a single circuit proving all 15 conditions.
65#[derive(Debug)]
66pub struct DelegationBundle {
67    /// The merged delegation circuit.
68    pub circuit: circuit::Circuit,
69    /// Public inputs (14 field elements).
70    pub instance: circuit::Instance,
71}
72
73/// Errors from delegation bundle construction.
74#[derive(Clone, Debug)]
75pub enum DelegationBuildError {
76    /// Must have 1–5 real notes.
77    InvalidNoteCount(usize),
78    /// IMT proof fetch failed for a padded note nullifier.
79    ImtFetchFailed(super::imt::ImtError),
80}
81
82impl From<super::imt::ImtError> for DelegationBuildError {
83    fn from(e: super::imt::ImtError) -> Self {
84        DelegationBuildError::ImtFetchFailed(e)
85    }
86}
87
88impl std::fmt::Display for DelegationBuildError {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        match self {
91            DelegationBuildError::InvalidNoteCount(n) => {
92                write!(f, "invalid note count: {} (expected 1–5)", n)
93            }
94            DelegationBuildError::ImtFetchFailed(e) => {
95                write!(f, "IMT proof fetch failed: {e}")
96            }
97        }
98    }
99}
100
101/// Build a complete delegation bundle with 1–5 real notes and padding.
102///
103/// # Arguments
104///
105/// - `real_notes`: 1–5 real notes with their keys, Merkle paths, and IMT proofs.
106/// - `fvk`: The delegator's full viewing key (shared across all real notes).
107/// - `alpha`: Spend auth randomizer for the keystone signature.
108/// - `output_recipient`: Address of the voting hotkey (output note recipient).
109/// - `vote_round_id`: Voting round identifier.
110/// - `nc_root`: Note commitment tree root (shared anchor).
111/// - `van_comm_rand`: Blinding factor for the governance commitment.
112/// - `imt_provider`: Provider for padded-note IMT non-membership proofs.
113/// - `rng`: Random number generator.
114/// - `precomputed`: If `Some`, reuse Phase 1 randomness for padded/signed/output notes
115///   (ZCA-74 fix). If `None`, sample fresh randomness (backward compat for tests).
116#[allow(clippy::too_many_arguments)]
117pub fn build_delegation_bundle(
118    real_notes: Vec<RealNoteInput>,
119    fvk: &FullViewingKey,
120    alpha: pallas::Scalar,
121    output_recipient: orchard::Address,
122    vote_round_id: pallas::Base,
123    nc_root: pallas::Base,
124    van_comm_rand: pallas::Base,
125    imt_provider: &impl ImtProvider,
126    rng: &mut impl RngCore,
127    precomputed: Option<&PrecomputedRandomness>,
128) -> Result<DelegationBundle, DelegationBuildError> {
129    // The circuit supports 1–5 real notes; reject empty or oversized bundles.
130    let n_real = real_notes.len();
131    if n_real == 0 || n_real > 5 {
132        return Err(DelegationBuildError::InvalidNoteCount(n_real));
133    }
134
135    // Snapshot the IMT root — all per-note non-membership proofs must be against this root.
136    let nf_imt_root = imt_provider.root();
137
138    // Derive key material.
139    let nk_val = fvk.nk().inner();
140    let ak: SpendValidatingKey = fvk.clone().into();
141
142    // Derive the nullifier domain for this round (ZIP §Nullifier Domains).
143    let dom = derive_nullifier_domain(vote_round_id);
144
145    // Collect per-note data.
146    let mut note_slots = Vec::with_capacity(5);
147    let mut cmx_values = Vec::with_capacity(5);
148    let mut v_values = Vec::with_capacity(5);
149    let mut gov_nulls = Vec::with_capacity(5);
150
151    // Process real notes: derive psi/rcm from rseed, compute the note commitment,
152    // real nullifier, and gov nullifier, then pack everything into a NoteSlotWitness.
153    for input in &real_notes {
154        let note = &input.note;
155        let rho = note.rho();
156        let psi = note.rseed().psi(&rho);
157        let rcm = note.rseed().rcm(&rho);
158        let cm = note.commitment();
159        let cmx = ExtractedNoteCommitment::from(cm.clone()).inner();
160        let v_raw = note.value().inner();
161        let recipient = note.recipient();
162
163        // Condition 12: real nullifier for IMT non-membership.
164        let real_nf = note.nullifier(fvk);
165        // Condition 14: alternate nullifier = Poseidon(nk, dom, real_nf).
166        let gov_null = gov_null_hash(nk_val, dom, real_nf.inner());
167
168        let slot = NoteSlotWitness {
169            g_d: Value::known(recipient.g_d()),
170            pk_d: Value::known(
171                NonIdentityPallasPoint::from_bytes(&recipient.pk_d().to_bytes()).unwrap(),
172            ),
173            v: Value::known(note.value()),
174            rho: Value::known(rho.into_inner()),
175            psi: Value::known(psi),
176            rcm: Value::known(rcm),
177            cm: Value::known(cm),
178            path: Value::known(input.merkle_path.auth_path()),
179            pos: Value::known(input.merkle_path.position()),
180            imt_nf_bounds: Value::known(input.imt_proof.nf_bounds),
181            imt_leaf_pos: Value::known(input.imt_proof.leaf_pos),
182            imt_path: Value::known(input.imt_proof.path),
183            is_internal: Value::known(matches!(input.scope, Scope::Internal)),
184        };
185
186        note_slots.push(slot);
187        cmx_values.push(cmx);
188        v_values.push(v_raw);
189        gov_nulls.push(gov_null);
190    }
191
192    // Pad remaining slots to 5 with zero-value dummy notes (ZIP §Note Padding).
193    // Dummy notes use v=0, which gates condition 10 (Merkle path) via
194    // v * (root - anchor) = 0. All other conditions run unconditionally.
195    for i in n_real..5 {
196        // Use a high diversifier index to avoid collision with real notes.
197        let pad_addr = fvk.address_at((1000 + i) as u32, Scope::External);
198        let pad_idx = i - n_real; // index into precomputed.padded_notes
199
200        let pad_note = if let Some(pre) = precomputed {
201            // ZCA-74: reuse Phase 1 randomness so the prover commits to the same values.
202            assert!(
203                pad_idx < pre.padded_notes.len(),
204                "precomputed.padded_notes has {} entries but need index {}",
205                pre.padded_notes.len(),
206                pad_idx
207            );
208            let pd = &pre.padded_notes[pad_idx];
209            let rho = Rho::from_bytes(&pd.rho).expect("precomputed rho must be valid");
210            let rseed =
211                RandomSeed::from_bytes(pd.rseed, &rho).expect("precomputed rseed must be valid");
212            Note::from_parts(pad_addr, NoteValue::ZERO, rho, rseed)
213                .expect("precomputed note must be valid")
214        } else {
215            let (_, _, dummy) = Note::dummy(&mut *rng, None);
216            Note::new(
217                pad_addr,
218                NoteValue::ZERO,
219                Rho::from_nf_old(dummy.nullifier(fvk)),
220                &mut *rng,
221            )
222        };
223
224        let rho = pad_note.rho();
225        let psi = pad_note.rseed().psi(&rho);
226        let rcm = pad_note.rseed().rcm(&rho);
227        let cm = pad_note.commitment();
228        let cmx = ExtractedNoteCommitment::from(cm.clone()).inner();
229
230        let real_nf = pad_note.nullifier(fvk);
231        let gov_null = gov_null_hash(nk_val, dom, real_nf.inner());
232
233        // Get IMT non-membership proof for this padded note's nullifier.
234        let imt_proof = imt_provider.non_membership_proof(real_nf.inner())?;
235
236        // Merkle path: dummy (condition 10 is skipped for v=0 notes).
237        let merkle_path = MerklePath::dummy(&mut *rng);
238
239        let slot = NoteSlotWitness {
240            g_d: Value::known(pad_addr.g_d()),
241            pk_d: Value::known(
242                NonIdentityPallasPoint::from_bytes(&pad_addr.pk_d().to_bytes()).unwrap(),
243            ),
244            v: Value::known(NoteValue::ZERO),
245            rho: Value::known(rho.into_inner()),
246            psi: Value::known(psi),
247            rcm: Value::known(rcm),
248            cm: Value::known(cm),
249            path: Value::known(merkle_path.auth_path()),
250            pos: Value::known(merkle_path.position()),
251            imt_nf_bounds: Value::known(imt_proof.nf_bounds),
252            imt_leaf_pos: Value::known(imt_proof.leaf_pos),
253            imt_path: Value::known(imt_proof.path),
254            is_internal: Value::known(false),
255        };
256
257        note_slots.push(slot);
258        cmx_values.push(cmx);
259        v_values.push(0);
260        gov_nulls.push(gov_null);
261    }
262
263    let notes: [NoteSlotWitness; 5] = note_slots.try_into().unwrap_or_else(|_| unreachable!());
264
265    // Condition 8: ballot scaling.
266    // num_ballots = floor(v_total / BALLOT_DIVISOR)
267    let v_total_u64: u64 = v_values.iter().sum();
268    let num_ballots_u64 = v_total_u64 / circuit::BALLOT_DIVISOR;
269    let remainder_u64 = v_total_u64 % circuit::BALLOT_DIVISOR;
270    let num_ballots_field = pallas::Base::from(num_ballots_u64);
271
272    // Condition 7: gov commitment integrity.
273    // van_comm = Poseidon(DOMAIN_VAN, g_d_new_x, pk_d_new_x, num_ballots,
274    //                     vote_round_id, MAX_PROPOSAL_AUTHORITY, van_comm_rand)
275    // Extract the output address as two x-coordinates (vpk representation).
276
277    let g_d_new_x = *output_recipient
278        .g_d()
279        .to_affine()
280        .coordinates()
281        .unwrap()
282        .x();
283    let pk_d_new_x = *output_recipient
284        .pk_d()
285        .inner()
286        .to_affine()
287        .coordinates()
288        .unwrap()
289        .x();
290
291    let van_comm = van_commitment_hash(
292        g_d_new_x,
293        pk_d_new_x,
294        num_ballots_field,
295        vote_round_id,
296        van_comm_rand,
297    );
298
299    // Condition 3: rho binding.
300    // rho_signed = Poseidon(cmx_1, cmx_2, cmx_3, cmx_4, cmx_5, van_comm, vote_round_id)
301    // Binds the keystone note to the exact notes being delegated.
302    let rho = rho_binding_hash(
303        cmx_values[0],
304        cmx_values[1],
305        cmx_values[2],
306        cmx_values[3],
307        cmx_values[4],
308        van_comm,
309        vote_round_id,
310    );
311
312    // Construct the keystone (signed) note (ZIP §Dummy Signed Note).
313    // Value is 1 so that hardware wallets (Keystone) render the transaction.
314    // The rho is bound to the delegation via condition 3.
315    let sender_address = fvk.address_at(0u32, Scope::External);
316    let signed_rho = Rho::from_nf_old(Nullifier::from_inner(rho));
317    let signed_note = if let Some(pre) = precomputed {
318        let rseed = RandomSeed::from_bytes(pre.rseed_signed, &signed_rho)
319            .expect("precomputed rseed_signed must be valid");
320        Note::from_parts(sender_address, NoteValue::from_raw(1), signed_rho, rseed)
321            .expect("precomputed signed note must be valid")
322    } else {
323        Note::new(
324            sender_address,
325            NoteValue::from_raw(1),
326            signed_rho,
327            &mut *rng,
328        )
329    };
330
331    // Condition 2: nullifier integrity — nf_signed is a public input.
332    let nf_signed = signed_note.nullifier(fvk);
333
334    // Condition 6: output note commitment integrity.
335    // The output note is sent to the voting hotkey address with rho = nf_signed.
336    let output_rho = Rho::from_nf_old(nf_signed);
337    let output_note = if let Some(pre) = precomputed {
338        let rseed = RandomSeed::from_bytes(pre.rseed_output, &output_rho)
339            .expect("precomputed rseed_output must be valid");
340        Note::from_parts(output_recipient, NoteValue::ZERO, output_rho, rseed)
341            .expect("precomputed output note must be valid")
342    } else {
343        Note::new(output_recipient, NoteValue::ZERO, output_rho, &mut *rng)
344    };
345    let cmx_new = ExtractedNoteCommitment::from(output_note.commitment()).inner();
346
347    // Condition 4: spend authority — rk is the randomized spend key.
348    let rk = ak.randomize(&alpha);
349
350    // Assemble the circuit (private witnesses) and instance (public inputs).
351    // The caller runs keygen + create_proof on the circuit, then submits
352    // the proof + instance to the vote chain. The verifier only needs
353    // the instance, proof, and verification key.
354    let circuit = circuit::Circuit::from_note_unchecked(fvk, &signed_note, alpha)
355        .with_output_note(&output_note)
356        .with_notes(notes)
357        .with_van_comm_rand(van_comm_rand)
358        .with_ballot_scaling(
359            pallas::Base::from(num_ballots_u64),
360            pallas::Base::from(remainder_u64),
361        );
362
363    let instance = circuit::Instance::from_parts(
364        nf_signed,
365        rk,
366        cmx_new,
367        van_comm,
368        vote_round_id,
369        nc_root,
370        nf_imt_root,
371        [
372            gov_nulls[0],
373            gov_nulls[1],
374            gov_nulls[2],
375            gov_nulls[3],
376            gov_nulls[4],
377        ],
378        dom,
379    );
380
381    Ok(DelegationBundle { circuit, instance })
382}
383
384// ================================================================
385// Test-only
386// ================================================================
387
388#[cfg(test)]
389mod tests {
390    use super::*;
391    use crate::delegation::imt::SpacedLeafImtProvider;
392    use ff::Field;
393    use halo2_proofs::dev::MockProver;
394    use incrementalmerkletree::{Hashable, Level};
395    use orchard::{
396        constants::MERKLE_DEPTH_ORCHARD,
397        keys::{FullViewingKey, Scope, SpendingKey},
398        note::{commitment::ExtractedNoteCommitment, Note, Rho},
399        tree::{MerkleHashOrchard, MerklePath},
400        value::NoteValue,
401    };
402    use pasta_curves::pallas;
403    use rand::rngs::OsRng;
404
405    /// Merged circuit K value.
406    const K: u32 = 14;
407
408    /// Helper: create 1–5 real note inputs with a shared Merkle tree and anchor.
409    ///
410    /// Notes are placed at positions 0..n in the commitment tree. Returns
411    /// `(inputs, nc_root)` where `nc_root` is the shared anchor.
412    ///
413    fn make_real_note_inputs(
414        fvk: &FullViewingKey,
415        values: &[u64],
416        scopes: &[Scope],
417        imt_provider: &impl ImtProvider,
418        rng: &mut impl RngCore,
419    ) -> (Vec<RealNoteInput>, pallas::Base) {
420        let n = values.len();
421        assert!(n >= 1 && n <= 5);
422        assert_eq!(n, scopes.len());
423
424        // Create notes.
425        let mut notes = Vec::with_capacity(n);
426        for (idx, &v) in values.iter().enumerate() {
427            let recipient = fvk.address_at(0u32, scopes[idx]);
428            let note_value = NoteValue::from_raw(v);
429            let (_, _, dummy_parent) = Note::dummy(&mut *rng, None);
430            let note = Note::new(
431                recipient,
432                note_value,
433                Rho::from_nf_old(dummy_parent.nullifier(fvk)),
434                &mut *rng,
435            );
436            notes.push(note);
437        }
438
439        // Extract leaf hashes, padding to 8 with empty leaves.
440        let empty_leaf = MerkleHashOrchard::empty_leaf();
441        let mut leaves = [empty_leaf; 8];
442        for (i, note) in notes.iter().enumerate() {
443            let cmx = ExtractedNoteCommitment::from(note.commitment());
444            leaves[i] = MerkleHashOrchard::from_cmx(&cmx);
445        }
446
447        // Build the bottom three levels of the shared tree (8-leaf tree).
448        let l1_0 = MerkleHashOrchard::combine(Level::from(0), &leaves[0], &leaves[1]);
449        let l1_1 = MerkleHashOrchard::combine(Level::from(0), &leaves[2], &leaves[3]);
450        let l1_2 = MerkleHashOrchard::combine(Level::from(0), &leaves[4], &leaves[5]);
451        let l1_3 = MerkleHashOrchard::combine(Level::from(0), &leaves[6], &leaves[7]);
452        let l2_0 = MerkleHashOrchard::combine(Level::from(1), &l1_0, &l1_1);
453        let l2_1 = MerkleHashOrchard::combine(Level::from(1), &l1_2, &l1_3);
454        let l3_0 = MerkleHashOrchard::combine(Level::from(2), &l2_0, &l2_1);
455
456        // Hash up through the remaining levels with empty subtree siblings.
457        let mut current = l3_0;
458        for level in 3..MERKLE_DEPTH_ORCHARD {
459            let sibling = MerkleHashOrchard::empty_root(Level::from(level as u8));
460            current = MerkleHashOrchard::combine(Level::from(level as u8), &current, &sibling);
461        }
462        let nc_root = current.inner();
463
464        // Build Merkle paths and RealNoteInputs.
465        let l1 = [l1_0, l1_1, l1_2, l1_3];
466        let l2 = [l2_0, l2_1];
467        let mut inputs = Vec::with_capacity(n);
468        for (i, note) in notes.into_iter().enumerate() {
469            let mut auth_path = [MerkleHashOrchard::empty_leaf(); MERKLE_DEPTH_ORCHARD];
470            auth_path[0] = leaves[i ^ 1];
471            auth_path[1] = l1[(i >> 1) ^ 1];
472            auth_path[2] = l2[1 - (i >> 2)];
473            for level in 3..MERKLE_DEPTH_ORCHARD {
474                auth_path[level] = MerkleHashOrchard::empty_root(Level::from(level as u8));
475            }
476            let merkle_path = MerklePath::from_parts(i as u32, auth_path);
477
478            let real_nf = note.nullifier(fvk);
479            let imt_proof = imt_provider.non_membership_proof(real_nf.inner()).unwrap();
480
481            inputs.push(RealNoteInput {
482                note,
483                fvk: fvk.clone(),
484                merkle_path,
485                imt_proof,
486                scope: scopes[i],
487            });
488        }
489
490        (inputs, nc_root)
491    }
492
493    /// Helper: build a bundle with explicit scopes and verify with MockProver.
494    fn build_and_verify(values: &[u64], scopes: &[Scope]) -> DelegationBundle {
495        assert_eq!(values.len(), scopes.len());
496        let mut rng = OsRng;
497        let sk = SpendingKey::random(&mut rng);
498        let fvk: FullViewingKey = (&sk).into();
499        let output_recipient = fvk.address_at(1u32, Scope::External);
500        let vote_round_id = pallas::Base::random(&mut rng);
501        let van_comm_rand = pallas::Base::random(&mut rng);
502        let alpha = pallas::Scalar::random(&mut rng);
503
504        let imt = SpacedLeafImtProvider::new();
505        let (inputs, nc_root) = make_real_note_inputs(&fvk, values, scopes, &imt, &mut rng);
506
507        let bundle = build_delegation_bundle(
508            inputs,
509            &fvk,
510            alpha,
511            output_recipient,
512            vote_round_id,
513            nc_root,
514            van_comm_rand,
515            &imt,
516            &mut rng,
517            None,
518        )
519        .unwrap();
520
521        // Verify merged circuit.
522        let pi = bundle.instance.to_halo2_instance();
523        let prover = MockProver::run(K, &bundle.circuit, vec![pi]).unwrap();
524        assert_eq!(prover.verify(), Ok(()), "merged circuit failed");
525
526        bundle
527    }
528
529    #[test]
530    fn test_single_real_note() {
531        build_and_verify(&[13_000_000], &[Scope::External]);
532    }
533
534    #[test]
535    fn test_four_real_notes() {
536        // 3,200,000 x 4 = 12,800,000 → num_ballots = 1, remainder = 300,000.
537        build_and_verify(
538            &[3_200_000, 3_200_000, 3_200_000, 3_200_000],
539            &[
540                Scope::External,
541                Scope::External,
542                Scope::External,
543                Scope::External,
544            ],
545        );
546    }
547
548    #[test]
549    fn test_two_real_notes() {
550        build_and_verify(&[7_000_000, 7_000_000], &[Scope::External, Scope::External]);
551    }
552
553    #[test]
554    fn test_min_weight_boundary() {
555        // v_total = 12,500,000 exactly → num_ballots = 1, remainder = 0. Should pass.
556        build_and_verify(&[12_500_000], &[Scope::External]);
557    }
558
559    #[test]
560    fn test_below_one_ballot() {
561        // v_total = 12,499,999 → num_ballots = 0. Circuit should fail
562        // (non-zero check on num_ballots causes nb_minus_one to wrap).
563        let mut rng = OsRng;
564        let sk = SpendingKey::random(&mut rng);
565        let fvk: FullViewingKey = (&sk).into();
566        let output_recipient = fvk.address_at(1u32, Scope::External);
567        let vote_round_id = pallas::Base::random(&mut rng);
568        let van_comm_rand = pallas::Base::random(&mut rng);
569        let alpha = pallas::Scalar::random(&mut rng);
570
571        let imt = SpacedLeafImtProvider::new();
572        let (inputs, nc_root) =
573            make_real_note_inputs(&fvk, &[12_499_999], &[Scope::External], &imt, &mut rng);
574
575        let bundle = build_delegation_bundle(
576            inputs,
577            &fvk,
578            alpha,
579            output_recipient,
580            vote_round_id,
581            nc_root,
582            van_comm_rand,
583            &imt,
584            &mut rng,
585            None,
586        )
587        .unwrap();
588
589        let pi = bundle.instance.to_halo2_instance();
590        let prover = MockProver::run(K, &bundle.circuit, vec![pi]).unwrap();
591        assert!(prover.verify().is_err(), "below one ballot should fail");
592    }
593
594    #[test]
595    fn test_three_ballots() {
596        // 3 notes × 12,500,000 = 37,500,000 → num_ballots = 3, remainder = 0.
597        build_and_verify(
598            &[12_500_000, 12_500_000, 12_500_000],
599            &[Scope::External, Scope::External, Scope::External],
600        );
601    }
602
603    #[test]
604    fn test_zero_notes_error() {
605        let mut rng = OsRng;
606        let sk = SpendingKey::random(&mut rng);
607        let fvk: FullViewingKey = (&sk).into();
608        let output_recipient = fvk.address_at(1u32, Scope::External);
609        let imt = SpacedLeafImtProvider::new();
610
611        let result = build_delegation_bundle(
612            vec![],
613            &fvk,
614            pallas::Scalar::random(&mut rng),
615            output_recipient,
616            pallas::Base::random(&mut rng),
617            pallas::Base::random(&mut rng),
618            pallas::Base::random(&mut rng),
619            &imt,
620            &mut rng,
621            None,
622        );
623
624        assert!(matches!(
625            result,
626            Err(DelegationBuildError::InvalidNoteCount(0))
627        ));
628    }
629
630    #[test]
631    fn test_five_real_notes() {
632        // 2,500,000 x 5 = 12,500,000 → num_ballots = 1, remainder = 0.
633        build_and_verify(
634            &[2_500_000, 2_500_000, 2_500_000, 2_500_000, 2_500_000],
635            &[
636                Scope::External,
637                Scope::External,
638                Scope::External,
639                Scope::External,
640                Scope::External,
641            ],
642        );
643    }
644
645    #[test]
646    fn test_six_notes_error() {
647        let mut rng = OsRng;
648        let sk = SpendingKey::random(&mut rng);
649        let fvk: FullViewingKey = (&sk).into();
650        let output_recipient = fvk.address_at(1u32, Scope::External);
651        let imt = SpacedLeafImtProvider::new();
652
653        let (inputs, _) = make_real_note_inputs(
654            &fvk,
655            &[3_000_000, 3_000_000, 3_000_000, 3_000_000, 3_000_000],
656            &[
657                Scope::External,
658                Scope::External,
659                Scope::External,
660                Scope::External,
661                Scope::External,
662            ],
663            &imt,
664            &mut rng,
665        );
666        // Add a 6th note by extending.
667        let mut inputs = inputs;
668        let (extra, _) =
669            make_real_note_inputs(&fvk, &[3_000_000], &[Scope::External], &imt, &mut rng);
670        inputs.extend(extra);
671
672        let result = build_delegation_bundle(
673            inputs,
674            &fvk,
675            pallas::Scalar::random(&mut rng),
676            output_recipient,
677            pallas::Base::random(&mut rng),
678            pallas::Base::random(&mut rng),
679            pallas::Base::random(&mut rng),
680            &imt,
681            &mut rng,
682            None,
683        );
684
685        assert!(matches!(
686            result,
687            Err(DelegationBuildError::InvalidNoteCount(6))
688        ));
689    }
690
691    #[test]
692    fn test_single_internal_note() {
693        build_and_verify(&[13_000_000], &[Scope::Internal]);
694    }
695
696    #[test]
697    fn test_mixed_scope_notes() {
698        build_and_verify(
699            &[4_000_000, 4_000_000, 3_000_000, 2_000_000],
700            &[
701                Scope::External,
702                Scope::Internal,
703                Scope::External,
704                Scope::Internal,
705            ],
706        );
707    }
708
709    #[test]
710    fn test_all_internal_notes() {
711        build_and_verify(
712            &[4_000_000, 4_000_000, 3_000_000, 2_000_000],
713            &[
714                Scope::Internal,
715                Scope::Internal,
716                Scope::Internal,
717                Scope::Internal,
718            ],
719        );
720    }
721}