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 alloc::vec::Vec;
9use group::Curve;
10use halo2_proofs::circuit::Value;
11use pasta_curves::{arithmetic::CurveAffine, pallas};
12use rand::RngCore;
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, van_commitment_hash, rho_binding_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.0);
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!(pad_idx < pre.padded_notes.len(),
203                "precomputed.padded_notes has {} entries but need index {}",
204                pre.padded_notes.len(), pad_idx);
205            let pd = &pre.padded_notes[pad_idx];
206            let rho = Rho::from_bytes(&pd.rho).expect("precomputed rho must be valid");
207            let rseed = RandomSeed::from_bytes(pd.rseed, &rho).expect("precomputed rseed must be valid");
208            Note::from_parts(pad_addr, NoteValue::zero(), rho, rseed).expect("precomputed note must be valid")
209        } else {
210            let (_, _, dummy) = Note::dummy(&mut *rng, None);
211            Note::new(
212                pad_addr,
213                NoteValue::zero(),
214                Rho::from_nf_old(dummy.nullifier(fvk)),
215                &mut *rng,
216            )
217        };
218
219        let rho = pad_note.rho();
220        let psi = pad_note.rseed().psi(&rho);
221        let rcm = pad_note.rseed().rcm(&rho);
222        let cm = pad_note.commitment();
223        let cmx = ExtractedNoteCommitment::from(cm.clone()).inner();
224
225        let real_nf = pad_note.nullifier(fvk);
226        let gov_null = gov_null_hash(nk_val, dom, real_nf.0);
227
228        // Get IMT non-membership proof for this padded note's nullifier.
229        let imt_proof = imt_provider.non_membership_proof(real_nf.0)?;
230
231        // Merkle path: dummy (condition 10 is skipped for v=0 notes).
232        let merkle_path = MerklePath::dummy(&mut *rng);
233
234        let slot = NoteSlotWitness {
235            g_d: Value::known(pad_addr.g_d()),
236            pk_d: Value::known(
237                NonIdentityPallasPoint::from_bytes(&pad_addr.pk_d().to_bytes()).unwrap(),
238            ),
239            v: Value::known(NoteValue::zero()),
240            rho: Value::known(rho.into_inner()),
241            psi: Value::known(psi),
242            rcm: Value::known(rcm),
243            cm: Value::known(cm),
244            path: Value::known(merkle_path.auth_path()),
245            pos: Value::known(merkle_path.position()),
246            imt_nf_bounds: Value::known(imt_proof.nf_bounds),
247            imt_leaf_pos: Value::known(imt_proof.leaf_pos),
248            imt_path: Value::known(imt_proof.path),
249            is_internal: Value::known(false),
250        };
251
252        note_slots.push(slot);
253        cmx_values.push(cmx);
254        v_values.push(0);
255        gov_nulls.push(gov_null);
256    }
257
258    let notes: [NoteSlotWitness; 5] = note_slots.try_into().unwrap_or_else(|_| unreachable!());
259
260    // Condition 8: ballot scaling.
261    // num_ballots = floor(v_total / BALLOT_DIVISOR)
262    let v_total_u64: u64 = v_values.iter().sum();
263    let num_ballots_u64 = v_total_u64 / circuit::BALLOT_DIVISOR;
264    let remainder_u64 = v_total_u64 % circuit::BALLOT_DIVISOR;
265    let num_ballots_field = pallas::Base::from(num_ballots_u64);
266
267    // Condition 7: gov commitment integrity.
268    // van_comm = Poseidon(DOMAIN_VAN, g_d_new_x, pk_d_new_x, num_ballots,
269    //                     vote_round_id, MAX_PROPOSAL_AUTHORITY, van_comm_rand)
270    // Extract the output address as two x-coordinates (vpk representation).
271
272    let g_d_new_x = *output_recipient
273        .g_d()
274        .to_affine()
275        .coordinates()
276        .unwrap()
277        .x();
278    let pk_d_new_x = *output_recipient
279        .pk_d()
280        .inner()
281        .to_affine()
282        .coordinates()
283        .unwrap()
284        .x();
285
286    let van_comm = van_commitment_hash(g_d_new_x, pk_d_new_x, num_ballots_field, vote_round_id, van_comm_rand);
287
288    // Condition 3: rho binding.
289    // rho_signed = Poseidon(cmx_1, cmx_2, cmx_3, cmx_4, cmx_5, van_comm, vote_round_id)
290    // Binds the keystone note to the exact notes being delegated.
291    let rho = rho_binding_hash(
292        cmx_values[0],
293        cmx_values[1],
294        cmx_values[2],
295        cmx_values[3],
296        cmx_values[4],
297        van_comm,
298        vote_round_id,
299    );
300
301    // Construct the keystone (signed) note (ZIP §Dummy Signed Note).
302    // Value is 1 so that hardware wallets (Keystone) render the transaction.
303    // The rho is bound to the delegation via condition 3.
304    let sender_address = fvk.address_at(0u32, Scope::External);
305    let signed_rho = Rho::from_nf_old(Nullifier(rho));
306    let signed_note = if let Some(pre) = precomputed {
307        let rseed = RandomSeed::from_bytes(pre.rseed_signed, &signed_rho)
308            .expect("precomputed rseed_signed must be valid");
309        Note::from_parts(sender_address, NoteValue::from_raw(1), signed_rho, rseed)
310            .expect("precomputed signed note must be valid")
311    } else {
312        Note::new(
313            sender_address,
314            NoteValue::from_raw(1),
315            signed_rho,
316            &mut *rng,
317        )
318    };
319
320    // Condition 2: nullifier integrity — nf_signed is a public input.
321    let nf_signed = signed_note.nullifier(fvk);
322
323    // Condition 6: output note commitment integrity.
324    // The output note is sent to the voting hotkey address with rho = nf_signed.
325    let output_rho = Rho::from_nf_old(nf_signed);
326    let output_note = if let Some(pre) = precomputed {
327        let rseed = RandomSeed::from_bytes(pre.rseed_output, &output_rho)
328            .expect("precomputed rseed_output must be valid");
329        Note::from_parts(output_recipient, NoteValue::zero(), output_rho, rseed)
330            .expect("precomputed output note must be valid")
331    } else {
332        Note::new(
333            output_recipient,
334            NoteValue::zero(),
335            output_rho,
336            &mut *rng,
337        )
338    };
339    let cmx_new = ExtractedNoteCommitment::from(output_note.commitment()).inner();
340
341    // Condition 4: spend authority — rk is the randomized spend key.
342    let rk = ak.randomize(&alpha);
343
344    // Assemble the circuit (private witnesses) and instance (public inputs).
345    // The caller runs keygen + create_proof on the circuit, then submits
346    // the proof + instance to the vote chain. The verifier only needs
347    // the instance, proof, and verification key.
348    let circuit = circuit::Circuit::from_note_unchecked(fvk, &signed_note, alpha)
349        .with_output_note(&output_note)
350        .with_notes(notes)
351        .with_van_comm_rand(van_comm_rand)
352        .with_ballot_scaling(
353            pallas::Base::from(num_ballots_u64),
354            pallas::Base::from(remainder_u64),
355        );
356
357    let instance = circuit::Instance::from_parts(
358        nf_signed,
359        rk,
360        cmx_new,
361        van_comm,
362        vote_round_id,
363        nc_root,
364        nf_imt_root,
365        [gov_nulls[0], gov_nulls[1], gov_nulls[2], gov_nulls[3], gov_nulls[4]],
366        dom,
367    );
368
369    Ok(DelegationBundle { circuit, instance })
370}
371
372// ================================================================
373// Test-only
374// ================================================================
375
376#[cfg(test)]
377mod tests {
378    use super::*;
379    use crate::delegation::imt::SpacedLeafImtProvider;
380    use orchard::{
381        constants::MERKLE_DEPTH_ORCHARD,
382        keys::{FullViewingKey, Scope, SpendingKey},
383        note::{commitment::ExtractedNoteCommitment, Note, Rho},
384        tree::{MerkleHashOrchard, MerklePath},
385        value::NoteValue,
386    };
387    use ff::Field;
388    use halo2_proofs::dev::MockProver;
389    use incrementalmerkletree::{Hashable, Level};
390    use pasta_curves::pallas;
391    use rand::rngs::OsRng;
392
393    /// Merged circuit K value.
394    const K: u32 = 14;
395
396    /// Helper: create 1–5 real note inputs with a shared Merkle tree and anchor.
397    ///
398    /// Notes are placed at positions 0..n in the commitment tree. Returns
399    /// `(inputs, nc_root)` where `nc_root` is the shared anchor.
400    ///
401    fn make_real_note_inputs(
402        fvk: &FullViewingKey,
403        values: &[u64],
404        scopes: &[Scope],
405        imt_provider: &impl ImtProvider,
406        rng: &mut impl RngCore,
407    ) -> (Vec<RealNoteInput>, pallas::Base) {
408        let n = values.len();
409        assert!(n >= 1 && n <= 5);
410        assert_eq!(n, scopes.len());
411
412        // Create notes.
413        let mut notes = Vec::with_capacity(n);
414        for (idx, &v) in values.iter().enumerate() {
415            let recipient = fvk.address_at(0u32, scopes[idx]);
416            let note_value = NoteValue::from_raw(v);
417            let (_, _, dummy_parent) = Note::dummy(&mut *rng, None);
418            let note = Note::new(
419                recipient,
420                note_value,
421                Rho::from_nf_old(dummy_parent.nullifier(fvk)),
422                &mut *rng,
423            );
424            notes.push(note);
425        }
426
427        // Extract leaf hashes, padding to 8 with empty leaves.
428        let empty_leaf = MerkleHashOrchard::empty_leaf();
429        let mut leaves = [empty_leaf; 8];
430        for (i, note) in notes.iter().enumerate() {
431            let cmx = ExtractedNoteCommitment::from(note.commitment());
432            leaves[i] = MerkleHashOrchard::from_cmx(&cmx);
433        }
434
435        // Build the bottom three levels of the shared tree (8-leaf tree).
436        let l1_0 = MerkleHashOrchard::combine(Level::from(0), &leaves[0], &leaves[1]);
437        let l1_1 = MerkleHashOrchard::combine(Level::from(0), &leaves[2], &leaves[3]);
438        let l1_2 = MerkleHashOrchard::combine(Level::from(0), &leaves[4], &leaves[5]);
439        let l1_3 = MerkleHashOrchard::combine(Level::from(0), &leaves[6], &leaves[7]);
440        let l2_0 = MerkleHashOrchard::combine(Level::from(1), &l1_0, &l1_1);
441        let l2_1 = MerkleHashOrchard::combine(Level::from(1), &l1_2, &l1_3);
442        let l3_0 = MerkleHashOrchard::combine(Level::from(2), &l2_0, &l2_1);
443
444        // Hash up through the remaining levels with empty subtree siblings.
445        let mut current = l3_0;
446        for level in 3..MERKLE_DEPTH_ORCHARD {
447            let sibling = MerkleHashOrchard::empty_root(Level::from(level as u8));
448            current = MerkleHashOrchard::combine(Level::from(level as u8), &current, &sibling);
449        }
450        let nc_root = current.inner();
451
452        // Build Merkle paths and RealNoteInputs.
453        let l1 = [l1_0, l1_1, l1_2, l1_3];
454        let l2 = [l2_0, l2_1];
455        let mut inputs = Vec::with_capacity(n);
456        for (i, note) in notes.into_iter().enumerate() {
457            let mut auth_path = [MerkleHashOrchard::empty_leaf(); MERKLE_DEPTH_ORCHARD];
458            auth_path[0] = leaves[i ^ 1];
459            auth_path[1] = l1[(i >> 1) ^ 1];
460            auth_path[2] = l2[1 - (i >> 2)];
461            for level in 3..MERKLE_DEPTH_ORCHARD {
462                auth_path[level] = MerkleHashOrchard::empty_root(Level::from(level as u8));
463            }
464            let merkle_path = MerklePath::from_parts(i as u32, auth_path);
465
466            let real_nf = note.nullifier(fvk);
467            let imt_proof = imt_provider.non_membership_proof(real_nf.0).unwrap();
468
469            inputs.push(RealNoteInput {
470                note,
471                fvk: fvk.clone(),
472                merkle_path,
473                imt_proof,
474                scope: scopes[i],
475            });
476        }
477
478        (inputs, nc_root)
479    }
480
481    /// Helper: build a bundle with explicit scopes and verify with MockProver.
482    fn build_and_verify(values: &[u64], scopes: &[Scope]) -> DelegationBundle {
483        assert_eq!(values.len(), scopes.len());
484        let mut rng = OsRng;
485        let sk = SpendingKey::random(&mut rng);
486        let fvk: FullViewingKey = (&sk).into();
487        let output_recipient = fvk.address_at(1u32, Scope::External);
488        let vote_round_id = pallas::Base::random(&mut rng);
489        let van_comm_rand = pallas::Base::random(&mut rng);
490        let alpha = pallas::Scalar::random(&mut rng);
491
492        let imt = SpacedLeafImtProvider::new();
493        let (inputs, nc_root) =
494            make_real_note_inputs(&fvk, values, scopes, &imt, &mut rng);
495
496        let bundle = build_delegation_bundle(
497            inputs,
498            &fvk,
499            alpha,
500            output_recipient,
501            vote_round_id,
502            nc_root,
503            van_comm_rand,
504            &imt,
505            &mut rng,
506            None,
507        )
508        .unwrap();
509
510        // Verify merged circuit.
511        let pi = bundle.instance.to_halo2_instance();
512        let prover = MockProver::run(K, &bundle.circuit, vec![pi]).unwrap();
513        assert_eq!(prover.verify(), Ok(()), "merged circuit failed");
514
515        bundle
516    }
517
518    #[test]
519    fn test_single_real_note() {
520        build_and_verify(&[13_000_000], &[Scope::External]);
521    }
522
523    #[test]
524    fn test_four_real_notes() {
525        // 3,200,000 x 4 = 12,800,000 → num_ballots = 1, remainder = 300,000.
526        build_and_verify(
527            &[3_200_000, 3_200_000, 3_200_000, 3_200_000],
528            &[Scope::External, Scope::External, Scope::External, Scope::External],
529        );
530    }
531
532    #[test]
533    fn test_two_real_notes() {
534        build_and_verify(&[7_000_000, 7_000_000], &[Scope::External, Scope::External]);
535    }
536
537    #[test]
538    fn test_min_weight_boundary() {
539        // v_total = 12,500,000 exactly → num_ballots = 1, remainder = 0. Should pass.
540        build_and_verify(&[12_500_000], &[Scope::External]);
541    }
542
543    #[test]
544    fn test_below_one_ballot() {
545        // v_total = 12,499,999 → num_ballots = 0. Circuit should fail
546        // (non-zero check on num_ballots causes nb_minus_one to wrap).
547        let mut rng = OsRng;
548        let sk = SpendingKey::random(&mut rng);
549        let fvk: FullViewingKey = (&sk).into();
550        let output_recipient = fvk.address_at(1u32, Scope::External);
551        let vote_round_id = pallas::Base::random(&mut rng);
552        let van_comm_rand = pallas::Base::random(&mut rng);
553        let alpha = pallas::Scalar::random(&mut rng);
554
555        let imt = SpacedLeafImtProvider::new();
556        let (inputs, nc_root) = make_real_note_inputs(&fvk, &[12_499_999], &[Scope::External], &imt, &mut rng);
557
558        let bundle = build_delegation_bundle(
559            inputs,
560            &fvk,
561            alpha,
562            output_recipient,
563            vote_round_id,
564            nc_root,
565            van_comm_rand,
566            &imt,
567            &mut rng,
568            None,
569        )
570        .unwrap();
571
572        let pi = bundle.instance.to_halo2_instance();
573        let prover = MockProver::run(K, &bundle.circuit, vec![pi]).unwrap();
574        assert!(prover.verify().is_err(), "below one ballot should fail");
575    }
576
577    #[test]
578    fn test_three_ballots() {
579        // 3 notes × 12,500,000 = 37,500,000 → num_ballots = 3, remainder = 0.
580        build_and_verify(
581            &[12_500_000, 12_500_000, 12_500_000],
582            &[Scope::External, Scope::External, Scope::External],
583        );
584    }
585
586    #[test]
587    fn test_zero_notes_error() {
588        let mut rng = OsRng;
589        let sk = SpendingKey::random(&mut rng);
590        let fvk: FullViewingKey = (&sk).into();
591        let output_recipient = fvk.address_at(1u32, Scope::External);
592        let imt = SpacedLeafImtProvider::new();
593
594        let result = build_delegation_bundle(
595            vec![],
596            &fvk,
597            pallas::Scalar::random(&mut rng),
598            output_recipient,
599            pallas::Base::random(&mut rng),
600            pallas::Base::random(&mut rng),
601            pallas::Base::random(&mut rng),
602            &imt,
603            &mut rng,
604            None,
605        );
606
607        assert!(matches!(
608            result,
609            Err(DelegationBuildError::InvalidNoteCount(0))
610        ));
611    }
612
613    #[test]
614    fn test_five_real_notes() {
615        // 2,500,000 x 5 = 12,500,000 → num_ballots = 1, remainder = 0.
616        build_and_verify(
617            &[2_500_000, 2_500_000, 2_500_000, 2_500_000, 2_500_000],
618            &[Scope::External, Scope::External, Scope::External, Scope::External, Scope::External],
619        );
620    }
621
622    #[test]
623    fn test_six_notes_error() {
624        let mut rng = OsRng;
625        let sk = SpendingKey::random(&mut rng);
626        let fvk: FullViewingKey = (&sk).into();
627        let output_recipient = fvk.address_at(1u32, Scope::External);
628        let imt = SpacedLeafImtProvider::new();
629
630        let (inputs, _) = make_real_note_inputs(
631            &fvk,
632            &[3_000_000, 3_000_000, 3_000_000, 3_000_000, 3_000_000],
633            &[Scope::External, Scope::External, Scope::External, Scope::External, Scope::External],
634            &imt,
635            &mut rng,
636        );
637        // Add a 6th note by extending.
638        let mut inputs = inputs;
639        let (extra, _) = make_real_note_inputs(&fvk, &[3_000_000], &[Scope::External], &imt, &mut rng);
640        inputs.extend(extra);
641
642        let result = build_delegation_bundle(
643            inputs,
644            &fvk,
645            pallas::Scalar::random(&mut rng),
646            output_recipient,
647            pallas::Base::random(&mut rng),
648            pallas::Base::random(&mut rng),
649            pallas::Base::random(&mut rng),
650            &imt,
651            &mut rng,
652            None,
653        );
654
655        assert!(matches!(
656            result,
657            Err(DelegationBuildError::InvalidNoteCount(6))
658        ));
659    }
660
661    #[test]
662    fn test_single_internal_note() {
663        build_and_verify(&[13_000_000], &[Scope::Internal]);
664    }
665
666    #[test]
667    fn test_mixed_scope_notes() {
668        build_and_verify(
669            &[4_000_000, 4_000_000, 3_000_000, 2_000_000],
670            &[Scope::External, Scope::Internal, Scope::External, Scope::Internal],
671        );
672    }
673
674    #[test]
675    fn test_all_internal_notes() {
676        build_and_verify(
677            &[4_000_000, 4_000_000, 3_000_000, 2_000_000],
678            &[Scope::Internal, Scope::Internal, Scope::Internal, Scope::Internal],
679        );
680    }
681}