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//! `circuit::MAX_REAL_NOTES` notes.
6//! Handles padding unused note slots with zero-value notes that still carry
7//! valid IMT non-membership proofs against the real tree root.
8
9use std::{iter, vec::Vec};
10
11use ff::{Field, PrimeField, PrimeFieldBits};
12use group::{Curve, GroupEncoding};
13use halo2_proofs::circuit::Value;
14use orchard::{
15    constants::{
16        fixed_bases::{COMMIT_IVK_PERSONALIZATION, NOTE_COMMITMENT_PERSONALIZATION},
17        L_ORCHARD_BASE, L_VALUE,
18    },
19    keys::{FullViewingKey, Scope, SpendValidatingKey},
20    note::{commitment::ExtractedNoteCommitment, nullifier::Nullifier, Note, RandomSeed, Rho},
21    spec::NonIdentityPallasPoint,
22    tree::MerklePath,
23    value::NoteValue,
24};
25use pasta_curves::{
26    arithmetic::{CurveAffine, CurveExt},
27    pallas,
28};
29use rand::{CryptoRng, RngCore};
30
31use super::{
32    circuit::{self, rho_binding_hash, van_commitment_hash, NoteSlotWitness},
33    imt::{derive_nullifier_domain, gov_null_hash, ImtProofData, ImtProvider},
34};
35use crate::{
36    gadgets::elgamal::base_to_scalar, params::BALLOT_DIVISOR, protocol_hash::poseidon_hash_2,
37};
38
39// Hash-to-curve personalization for synthetic padding `g_d_pad` points.
40//
41// Domain-separated from Orchard's `KEY_DIVERSIFICATION_PERSONALIZATION`
42// (`"z.cash:Orchard-gd"`) so that `g_d_pad = hash_to_curve(PADDING_PERSONALIZATION)(...)`
43// is not generated by the same diversified-address construction as real Orchard
44// bases. Any accidental point collision is treated as a negligible hash-to-curve
45// collision rather than a burned diversifier-index overlap.
46const PADDING_PERSONALIZATION: &str = "shielded-vote/padding-v1";
47
48/// Rho and rseed for a single padded note, captured during Phase 1 (PCZT construction).
49#[derive(Clone, Debug)]
50pub struct PaddedNoteData {
51    /// Rho bytes (32 bytes, LE encoding of pallas::Base).
52    pub rho: [u8; 32],
53    /// Random seed bytes (32 bytes).
54    pub rseed: [u8; 32],
55}
56
57/// Randomness captured during Phase 1 (PCZT construction) that must be reused
58/// in Phase 2 (ZK proving) so the prover commits to the same nf_signed/cmx_new
59/// that the signer committed to via the ZIP-244 sighash.
60#[derive(Clone, Debug)]
61pub struct PrecomputedRandomness {
62    /// Rho + rseed for each padded note (0–4 entries).
63    pub padded_notes: Vec<PaddedNoteData>,
64    /// Rseed for the signed (keystone) note.
65    pub rseed_signed: [u8; 32],
66    /// Rseed for the output note.
67    pub rseed_output: [u8; 32],
68}
69
70/// Which precomputed note input failed validation.
71#[derive(Clone, Copy, Debug, PartialEq, Eq)]
72#[allow(clippy::enum_variant_names)]
73pub enum PrecomputedRandomnessLocation {
74    /// A padded note entry by index in `PrecomputedRandomness::padded_notes`.
75    PaddedNote(usize),
76    /// The synthetic signed note.
77    SignedNote,
78    /// The output note.
79    OutputNote,
80}
81
82/// Input for a single real note in the delegation.
83#[derive(Debug)]
84pub struct RealNoteInput {
85    /// The note being delegated.
86    pub note: Note,
87    /// The note's full viewing key.
88    pub fvk: FullViewingKey,
89    /// Merkle authentication path for the note commitment.
90    pub merkle_path: MerklePath,
91    /// IMT non-membership proof for this note's nullifier.
92    ///
93    /// This must satisfy [`ImtProofData`]'s tree contract and authenticate to
94    /// the same nullifier IMT root used for the bundle.
95    pub imt_proof: ImtProofData,
96    /// Whether this note uses the internal (change) or external scope.
97    pub scope: Scope,
98}
99
100/// Complete delegation bundle: a single circuit proving all 15 conditions.
101#[derive(Debug)]
102pub struct DelegationBundle {
103    /// The merged delegation circuit.
104    pub circuit: circuit::Circuit,
105    /// Public inputs (14 field elements).
106    pub instance: circuit::Instance,
107}
108
109// `ExtractP` from the Orchard spec: the x-coordinate of a non-identity Pallas
110// point. Panics on identity.
111// Note: Orchard's `spec::extract_p` is `pub(crate)`; we mirror it here.
112fn point_x(point: &pallas::Point) -> pallas::Base {
113    point_x_opt(point).expect("ExtractP requires a non-identity Pallas point")
114}
115
116fn point_x_opt(point: &pallas::Point) -> Option<pallas::Base> {
117    point
118        .to_affine()
119        .coordinates()
120        .into_option()
121        .map(|coords| *coords.x())
122}
123
124// Expands a 32-byte compressed encoding into a stream of little-endian bits.
125// Orchard’s note commitment hashes `g_d` and `pk_d` as
126// little-endian bits of their 32-byte compressed encodings.
127fn byte_bits(bytes: [u8; 32]) -> impl Iterator<Item = bool> {
128    bytes
129        .into_iter()
130        .flat_map(|byte| (0..8).map(move |bit| ((byte >> bit) & 1) == 1))
131}
132
133// Expands a 64-bit little-endian integer into a stream of little-endian bits.
134// Orchard’s note commitment hashes `value` as a 64-bit little-endian integer.
135fn u64_bits(value: u64) -> impl Iterator<Item = bool> {
136    value
137        .to_le_bytes()
138        .into_iter()
139        .flat_map(|byte| (0..8).map(move |bit| ((byte >> bit) & 1) == 1))
140}
141
142// Derives the external IVK scalar from the full viewing key and spend validating key.
143// Used to construct padding `(g_d_pad, pk_d_pad)` pairs such that the in-circuit
144// `pk_d = [selected_ivk] * g_d` check (condition 11) holds against the same ivk
145// the circuit derives in condition 5. Padding always uses `is_internal = false`,
146// so the external rivk is the correct scope.
147// Note: Orchard's `FullViewingKey::to_ivk` does not expose the inner Ivk scalar;
148// if it did (e.g. `fvk.ivk_scalar(scope)`), we could drop this re-implementation.
149fn external_ivk_scalar(fvk: &FullViewingKey, ak: &SpendValidatingKey) -> pallas::Scalar {
150    let ak_point: pallas::Point = ak.into();
151    let ak_x = point_x(&ak_point);
152    let rivk = fvk.rivk(Scope::External).inner();
153    let domain = sinsemilla::CommitDomain::new(COMMIT_IVK_PERSONALIZATION);
154    let ivk = domain
155        .short_commit(
156            iter::empty()
157                .chain(ak_x.to_le_bits().iter().by_vals().take(L_ORCHARD_BASE))
158                .chain(
159                    fvk.nk()
160                        .inner()
161                        .to_le_bits()
162                        .iter()
163                        .by_vals()
164                        .take(L_ORCHARD_BASE),
165                ),
166            &rivk,
167        )
168        .expect("external ivk must not be bottom");
169    base_to_scalar(ivk).expect("external ivk must fit in the scalar field")
170}
171
172// Wraps a `pallas::Point` as `NonIdentityPallasPoint` without panicking on identity.
173//
174// Synthesized padding points come from `hash_to_curve` and scalar multiplication
175// by `ivk`; both are cryptographically non-identity (identity from `hash_to_curve`
176// is negligible, and `ivk = 0` is already rejected by `CommitIvk`'s ⊥ branch upstream).
177// We validate here so the invariant fails at the construction site rather than
178// silently flowing into the witness — the in-circuit `NonIdentityPoint::new`
179// would catch identity at proof time, but a build-time error is cheaper to debug.
180fn non_identity_padding_point(
181    point: pallas::Point,
182    slot_index: usize,
183    component: &'static str,
184) -> Result<NonIdentityPallasPoint, DelegationBuildError> {
185    NonIdentityPallasPoint::from_bytes(&point.to_bytes())
186        .into_option()
187        .ok_or(DelegationBuildError::InvalidPaddingPoint {
188            slot_index,
189            component,
190        })
191}
192
193fn validate_padding_slot_index(slot_index: usize) -> Result<(), DelegationBuildError> {
194    if (1..circuit::MAX_REAL_NOTES).contains(&slot_index) {
195        Ok(())
196    } else {
197        Err(DelegationBuildError::InvalidPaddingSlotIndex { slot_index })
198    }
199}
200
201// Derives the synthetic `(g_d_pad, pk_d_pad)` pair for padding slot `slot_index`.
202// `g_d_pad` is domain-separated from Orchard's `DiversifyHash`, so the pair is
203// intentionally not a valid `orchard::Address`. `pk_d_pad = [ivk] * g_d_pad`
204// satisfies condition 11 by construction (callers must pass the external ivk,
205// since padding pins `is_internal = false`). Both points are wrapped as
206// `NonIdentityPallasPoint` via `non_identity_padding_point` so the invariant fails at
207// the builder rather than at proof time.
208fn padding_points(
209    slot_index: usize,
210    ivk: pallas::Scalar,
211) -> Result<(NonIdentityPallasPoint, NonIdentityPallasPoint), DelegationBuildError> {
212    validate_padding_slot_index(slot_index)?;
213    let slot_index_u32 =
214        u32::try_from(slot_index).expect("validated padding slot index fits in u32");
215    let g_d_pad =
216        pallas::Point::hash_to_curve(PADDING_PERSONALIZATION)(&slot_index_u32.to_le_bytes());
217    let pk_d_pad = g_d_pad * ivk;
218    Ok((
219        non_identity_padding_point(g_d_pad, slot_index, "g_d")?,
220        non_identity_padding_point(pk_d_pad, slot_index, "pk_d")?,
221    ))
222}
223
224// Generates a random seed for a given rho.
225// The random seed is used to derive the psi and rcm for the note.
226// Note: Orchard's RandomSeed::random is not exposed. If it was exposed,
227// we could use it here instead of sampling a random seed.
228fn random_seed_for_rho(rho: &Rho, rng: &mut impl RngCore) -> RandomSeed {
229    loop {
230        let mut rseed = [0u8; 32];
231        rng.fill_bytes(&mut rseed);
232        let rseed = RandomSeed::from_bytes(rseed, rho);
233        if bool::from(rseed.is_some()) {
234            return rseed.unwrap();
235        }
236    }
237}
238
239// Derives the note commitment point for a synthetic padding note.
240// Orchard has this low-level operation internally as NoteCommitment::derive,
241// but does not expose it as a public helper. Mirror it here so padding can use
242// domain-separated synthetic (g_d, pk_d) points.
243//
244// Sinsemilla returns `None` precisely when the commitment would be the identity
245// ("bottom"), so callers must convert that into a recoverable build error.
246fn note_commitment_point(
247    g_d: pallas::Point,
248    pk_d: pallas::Point,
249    value: NoteValue,
250    rho: pallas::Base,
251    psi: pallas::Base,
252    rcm: pallas::Scalar,
253) -> Option<pallas::Point> {
254    let domain = sinsemilla::CommitDomain::new(NOTE_COMMITMENT_PERSONALIZATION);
255    // Mirrors Orchard NoteCommit while allowing synthetic padding points.
256    domain
257        .commit(
258            iter::empty()
259                .chain(byte_bits(g_d.to_bytes()))
260                .chain(byte_bits(pk_d.to_bytes()))
261                .chain(u64_bits(value.inner()).take(L_VALUE))
262                .chain(rho.to_le_bits().iter().by_vals().take(L_ORCHARD_BASE))
263                .chain(psi.to_le_bits().iter().by_vals().take(L_ORCHARD_BASE)),
264            &rcm,
265        )
266        .into_option()
267}
268
269// Derives the note nullifier for a given note.
270// Note: Orchard has this low-level operation internally as Note::nullifier,
271// but does not expose it as a public helper. Mirror it here so padding can use
272// the same derivation logic.
273fn derive_note_nullifier(
274    nk: pallas::Base,
275    rho: pallas::Base,
276    psi: pallas::Base,
277    cm: pallas::Affine,
278) -> Option<pallas::Base> {
279    let k = pallas::Point::hash_to_curve("z.cash:Orchard")(b"K");
280    let prf_nf = poseidon_hash_2(nk, rho);
281    // Pallas base elements embed directly into the larger scalar field.
282    let scalar = pallas::Scalar::from_repr((prf_nf + psi).to_repr())
283        .expect("Pallas base field is smaller than its scalar field");
284    point_x_opt(&(k * scalar + cm))
285}
286
287/// Off-circuit `(cmx, nullifier)` for a synthetic padding slot, in canonical
288/// little-endian byte encoding.
289///
290/// The values match what the delegation circuit constrains for the padded
291/// slot, so downstream consumers (PCZT metadata, PIR precompute, IMT
292/// non-membership lookups) can reference the same padding-slot commitment
293/// x-coordinate and real Orchard nullifier the prover witnesses without
294/// re-implementing the synthetic padding derivation.
295#[derive(Clone, Copy, Debug, PartialEq, Eq)]
296pub struct SyntheticPaddingNoteParts {
297    /// Extracted x-coordinate of the synthetic padding note commitment.
298    pub cmx: [u8; 32],
299    /// Real Orchard nullifier of the synthetic padding note (the one
300    /// `gov_null` is derived from).
301    pub nullifier: [u8; 32],
302}
303
304struct SyntheticPaddingDerivation {
305    g_d_pad: NonIdentityPallasPoint,
306    pk_d_pad: NonIdentityPallasPoint,
307    psi: pallas::Base,
308    rcm: orchard::note::NoteCommitTrapdoor,
309    cm: pallas::Affine,
310    cmx: pallas::Base,
311    real_nf: pallas::Base,
312}
313
314// Derives every off-circuit value for a synthetic padding note from one shared
315// construction, so public helper output and in-circuit witnesses cannot drift.
316fn derive_synthetic_padding_note(
317    nk: pallas::Base,
318    ivk: pallas::Scalar,
319    slot_index: usize,
320    rho: Rho,
321    rseed: RandomSeed,
322    location: PrecomputedRandomnessLocation,
323) -> Result<SyntheticPaddingDerivation, DelegationBuildError> {
324    // Padding uses synthetic address components bound to the external IVK; the
325    // rho-scoped seed then supplies the Orchard commitment randomness.
326    let (g_d_pad, pk_d_pad) = padding_points(slot_index, ivk)?;
327    let psi = rseed.psi(&rho);
328    let rcm = rseed.rcm(&rho);
329
330    // Commitment and nullifier derivation can bottom out only in negligible
331    // group edge cases; surface those as build errors at the caller's location.
332    let cm = note_commitment_point(
333        *g_d_pad,
334        *pk_d_pad,
335        NoteValue::ZERO,
336        rho.into_inner(),
337        psi,
338        rcm.inner(),
339    )
340    .ok_or(DelegationBuildError::InvalidPaddingNoteCommitment { location })?
341    .to_affine();
342    let cmx = *cm
343        .coordinates()
344        .into_option()
345        .ok_or(DelegationBuildError::InvalidPaddingNoteCommitment { location })?
346        .x();
347    let real_nf = derive_note_nullifier(nk, rho.into_inner(), psi, cm)
348        .ok_or(DelegationBuildError::InvalidPaddingNullifier { location })?;
349
350    Ok(SyntheticPaddingDerivation {
351        g_d_pad,
352        pk_d_pad,
353        psi,
354        rcm,
355        cm,
356        cmx,
357        real_nf,
358    })
359}
360
361/// Computes the off-circuit `(cmx, nullifier)` for a synthetic padding slot
362/// from the same construction the delegation builder uses in-circuit.
363///
364/// Padding slots are not Orchard diversified addresses: `g_d_pad` is
365/// hash-to-curve under [`PADDING_PERSONALIZATION`] (domain-separated from
366/// Orchard's `DiversifyHash`) and `pk_d_pad = [ivk_external] * g_d_pad`.
367/// Callers must supply the same `slot_index`, `rho`, and `rseed` the
368/// delegation builder will see during proving (e.g. via
369/// [`PaddedNoteData`]) so the off-circuit `cmx`/`nullifier` agree with
370/// the circuit's witnessed values bit-for-bit.
371///
372/// Valid `slot_index` values are circuit padding slots `1..MAX_REAL_NOTES`
373/// (`1..=4` for the current fixed-width circuit); slot `0` is always a real
374/// note slot and `MAX_REAL_NOTES` is outside the circuit shape.
375///
376/// Returns [`DelegationBuildError::InvalidPaddingSlotIndex`],
377/// [`DelegationBuildError::InvalidPaddingPoint`],
378/// [`DelegationBuildError::InvalidPaddingNoteCommitment`], or
379/// [`DelegationBuildError::InvalidPaddingNullifier`] for the
380/// slot validation error and cryptographically negligible failure modes
381/// (identity point, Sinsemilla bottom, identity nullifier point). Errors are reported against
382/// [`PrecomputedRandomnessLocation::PaddedNote`] with the supplied
383/// `slot_index`.
384pub fn synthetic_padding_note_parts(
385    fvk: &FullViewingKey,
386    slot_index: usize,
387    rho: Rho,
388    rseed: RandomSeed,
389) -> Result<SyntheticPaddingNoteParts, DelegationBuildError> {
390    let ak: SpendValidatingKey = fvk.clone().into();
391    let ivk = external_ivk_scalar(fvk, &ak);
392    let location = PrecomputedRandomnessLocation::PaddedNote(slot_index);
393    let padding =
394        derive_synthetic_padding_note(fvk.nk().inner(), ivk, slot_index, rho, rseed, location)?;
395
396    Ok(SyntheticPaddingNoteParts {
397        cmx: padding.cmx.to_repr(),
398        nullifier: padding.real_nf.to_repr(),
399    })
400}
401
402// A single padding note slot in the delegation.
403struct PaddingSlot {
404    witness: NoteSlotWitness,
405    cmx: pallas::Base,
406    v_raw: u64,
407    gov_null: pallas::Base,
408    #[cfg(test)]
409    real_nf: pallas::Base,
410}
411
412fn build_padding_slot(
413    slot_index: usize,
414    pad_idx: usize,
415    nk: pallas::Base,
416    dom: pallas::Base,
417    ivk: pallas::Scalar,
418    imt_provider: &impl ImtProvider,
419    rng: &mut impl RngCore,
420    precomputed: Option<&PrecomputedRandomness>,
421) -> Result<PaddingSlot, DelegationBuildError> {
422    let location = PrecomputedRandomnessLocation::PaddedNote(pad_idx);
423
424    let (rho, rseed) = if let Some(pre) = precomputed {
425        // Reuse randomness so the prover commits to the same values.
426        if pad_idx >= pre.padded_notes.len() {
427            return Err(DelegationBuildError::MissingPrecomputedPaddedNote {
428                index: pad_idx,
429                actual: pre.padded_notes.len(),
430            });
431        }
432        let pd = &pre.padded_notes[pad_idx];
433        let rho = Rho::from_bytes(&pd.rho)
434            .into_option()
435            .ok_or(DelegationBuildError::InvalidPrecomputedRho { index: pad_idx })?;
436        let rseed = RandomSeed::from_bytes(pd.rseed, &rho)
437            .into_option()
438            .ok_or(DelegationBuildError::InvalidPrecomputedRseed { location })?;
439        (rho, rseed)
440    } else {
441        let rho = Rho::from_nf_old(Nullifier::from_inner(pallas::Base::random(&mut *rng)));
442        let rseed = random_seed_for_rho(&rho, &mut *rng);
443        (rho, rseed)
444    };
445
446    let padding = derive_synthetic_padding_note(nk, ivk, slot_index, rho, rseed, location)?;
447    let gov_null = gov_null_hash(nk, dom, padding.real_nf);
448    let imt_proof = imt_provider.non_membership_proof(padding.real_nf)?;
449
450    // Merkle path is unconstrained for zero-value padding because condition 10
451    // is gated by v=0; IMT non-membership and address ownership still run.
452    let merkle_path = MerklePath::dummy(&mut *rng);
453    let witness = NoteSlotWitness {
454        g_d: Value::known(padding.g_d_pad),
455        pk_d: Value::known(padding.pk_d_pad),
456        v: Value::known(NoteValue::ZERO),
457        rho: Value::known(rho.into_inner()),
458        psi: Value::known(padding.psi),
459        rcm: Value::known(padding.rcm),
460        cm: Value::known(padding.cm),
461        path: Value::known(merkle_path.auth_path()),
462        pos: Value::known(merkle_path.position()),
463        imt_nf_bounds: Value::known(imt_proof.nf_bounds),
464        imt_leaf_pos: Value::known(imt_proof.leaf_pos),
465        imt_path: Value::known(imt_proof.path),
466        is_internal: Value::known(false),
467    };
468
469    Ok(PaddingSlot {
470        witness,
471        cmx: padding.cmx,
472        v_raw: 0,
473        gov_null,
474        #[cfg(test)]
475        real_nf: padding.real_nf,
476    })
477}
478
479#[cfg(test)]
480pub(super) struct PaddingSlotForTesting {
481    pub witness: NoteSlotWitness,
482    pub cmx: pallas::Base,
483    pub gov_null: pallas::Base,
484    pub real_nf: pallas::Base,
485}
486
487#[cfg(test)]
488pub(super) fn build_padding_slot_for_testing(
489    slot_index: usize,
490    pad_idx: usize,
491    fvk: &FullViewingKey,
492    ak: &SpendValidatingKey,
493    dom: pallas::Base,
494    imt_provider: &impl ImtProvider,
495    rng: &mut impl RngCore,
496) -> Result<PaddingSlotForTesting, DelegationBuildError> {
497    let padding = build_padding_slot(
498        slot_index,
499        pad_idx,
500        fvk.nk().inner(),
501        dom,
502        external_ivk_scalar(fvk, ak),
503        imt_provider,
504        rng,
505        None,
506    )?;
507
508    Ok(PaddingSlotForTesting {
509        witness: padding.witness,
510        cmx: padding.cmx,
511        gov_null: padding.gov_null,
512        real_nf: padding.real_nf,
513    })
514}
515
516/// Errors from delegation bundle construction.
517#[derive(Clone, Debug)]
518pub enum DelegationBuildError {
519    /// Must have 1 to `circuit::MAX_REAL_NOTES` real notes.
520    InvalidNoteCount(usize),
521    /// Public input construction failed.
522    Instance(circuit::InstanceError),
523    /// Padding can only occupy circuit slots 1..`circuit::MAX_REAL_NOTES`.
524    InvalidPaddingSlotIndex { slot_index: usize },
525    /// A synthesized padding point was the identity.
526    InvalidPaddingPoint {
527        slot_index: usize,
528        component: &'static str,
529    },
530    /// A synthesized padding note commitment bottomed out.
531    InvalidPaddingNoteCommitment {
532        location: PrecomputedRandomnessLocation,
533    },
534    /// A synthesized padding note nullifier bottomed out.
535    InvalidPaddingNullifier {
536        location: PrecomputedRandomnessLocation,
537    },
538    /// A required precomputed padded note entry is missing.
539    MissingPrecomputedPaddedNote { index: usize, actual: usize },
540    /// A precomputed padded note rho is not a canonical field encoding.
541    InvalidPrecomputedRho { index: usize },
542    /// A precomputed rseed is not valid for the note rho.
543    InvalidPrecomputedRseed {
544        location: PrecomputedRandomnessLocation,
545    },
546    /// Precomputed note components do not produce a valid Orchard note.
547    InvalidPrecomputedNote {
548        location: PrecomputedRandomnessLocation,
549    },
550    /// IMT proof fetch failed for a padded note nullifier.
551    ImtFetchFailed(super::imt::ImtError),
552}
553
554impl From<circuit::InstanceError> for DelegationBuildError {
555    fn from(e: circuit::InstanceError) -> Self {
556        DelegationBuildError::Instance(e)
557    }
558}
559
560impl From<super::imt::ImtError> for DelegationBuildError {
561    fn from(e: super::imt::ImtError) -> Self {
562        DelegationBuildError::ImtFetchFailed(e)
563    }
564}
565
566impl std::fmt::Display for DelegationBuildError {
567    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
568        match self {
569            DelegationBuildError::InvalidNoteCount(n) => {
570                write!(
571                    f,
572                    "invalid note count: {} (expected 1–{})",
573                    n,
574                    circuit::MAX_REAL_NOTES
575                )
576            }
577            DelegationBuildError::Instance(e) => {
578                write!(f, "instance construction failed: {e}")
579            }
580            DelegationBuildError::InvalidPaddingSlotIndex { slot_index } => {
581                write!(
582                    f,
583                    "invalid padding slot index {slot_index} (expected 1..={})",
584                    circuit::MAX_REAL_NOTES - 1
585                )
586            }
587            DelegationBuildError::InvalidPaddingPoint {
588                slot_index,
589                component,
590            } => {
591                write!(
592                    f,
593                    "invalid padding point {component} at slot {slot_index}: identity point"
594                )
595            }
596            DelegationBuildError::InvalidPaddingNoteCommitment { location } => {
597                write!(f, "invalid padding note commitment for {location}")
598            }
599            DelegationBuildError::InvalidPaddingNullifier { location } => {
600                write!(f, "invalid padding nullifier for {location}")
601            }
602            DelegationBuildError::MissingPrecomputedPaddedNote { index, actual } => {
603                write!(
604                    f,
605                    "missing precomputed padded note at index {index} (got {actual} entries)"
606                )
607            }
608            DelegationBuildError::InvalidPrecomputedRho { index } => {
609                write!(f, "invalid precomputed padded note rho at index {index}")
610            }
611            DelegationBuildError::InvalidPrecomputedRseed { location } => {
612                write!(f, "invalid precomputed rseed for {location}")
613            }
614            DelegationBuildError::InvalidPrecomputedNote { location } => {
615                write!(f, "invalid precomputed note components for {location}")
616            }
617            DelegationBuildError::ImtFetchFailed(e) => {
618                write!(f, "IMT proof fetch failed: {e}")
619            }
620        }
621    }
622}
623
624impl std::fmt::Display for PrecomputedRandomnessLocation {
625    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
626        match self {
627            PrecomputedRandomnessLocation::PaddedNote(index) => {
628                write!(f, "padded note {index}")
629            }
630            PrecomputedRandomnessLocation::SignedNote => write!(f, "signed note"),
631            PrecomputedRandomnessLocation::OutputNote => write!(f, "output note"),
632        }
633    }
634}
635
636/// Build a complete delegation bundle with 1 to `circuit::MAX_REAL_NOTES`
637/// real notes and padding.
638///
639/// # Arguments
640///
641/// - `real_notes`: 1 to `circuit::MAX_REAL_NOTES` real notes with their keys,
642///   Merkle paths, and IMT proofs.
643/// - `fvk`: The delegator's full viewing key (shared across all real notes).
644/// - `alpha`: Spend auth randomizer for the keystone signature.
645/// - `output_recipient`: Address of the voting hotkey (output note recipient).
646/// - `vote_round_id`: Voting round identifier.
647/// - `nc_root`: Note commitment tree root (shared ledger-state anchor).
648///   The caller must pin this from the chain's note commitment tree at the
649///   verifier-accepted snapshot height; the builder does not authenticate it.
650/// - `van_comm_rand`: Blinding factor for the governance commitment.
651/// - `imt_provider`: Provider for the bundle-wide alternate-nullifier IMT root
652///   and padded-note IMT non-membership proofs. Every real-note proof in
653///   `real_notes` must authenticate to this provider's root, and the caller
654///   must ensure the provider root is from the same ledger snapshot as
655///   `nc_root`.
656/// - `rng`: Cryptographically secure random number generator.
657/// - `precomputed`: If `Some`, reuse Phase 1 randomness for padded/signed/output notes
658///   (ZCA-74 fix). If `None`, sample fresh randomness (backward compat for tests).
659///
660/// # Caller contract
661///
662/// `alpha` and `van_comm_rand` are secret, one-time blinding scalars and MUST
663/// be drawn from a CSPRNG such as `OsRng` for each delegation bundle.
664/// `vote_round_id`, `nc_root`, and the IMT root behind `imt_provider` are
665/// authenticated session parameters: the builder constrains proofs to the
666/// supplied values but cannot prove they came from the intended chain snapshot
667/// or governance announcement. `rng` is used for note seed material and dummy
668/// Merkle paths when randomness is not supplied via `precomputed`; the
669/// security-critical blinding scalars remain caller-supplied.
670pub fn build_delegation_bundle(
671    real_notes: Vec<RealNoteInput>,
672    fvk: &FullViewingKey,
673    alpha: pallas::Scalar,
674    output_recipient: orchard::Address,
675    vote_round_id: pallas::Base,
676    nc_root: pallas::Base,
677    van_comm_rand: pallas::Base,
678    imt_provider: &impl ImtProvider,
679    rng: &mut (impl RngCore + CryptoRng),
680    precomputed: Option<&PrecomputedRandomness>,
681) -> Result<DelegationBundle, DelegationBuildError> {
682    // The circuit exposes a fixed MAX_REAL_NOTES shape; callers split larger
683    // wallets into multiple delegation proofs rather than changing the VK.
684    let n_real = real_notes.len();
685    if n_real == 0 || n_real > circuit::MAX_REAL_NOTES {
686        return Err(DelegationBuildError::InvalidNoteCount(n_real));
687    }
688
689    // Snapshot the IMT root — all per-note non-membership proofs must be against this root.
690    let nf_imt_root = imt_provider.root();
691
692    // Derive key material.
693    let nk_val = fvk.nk().inner();
694    let ak: SpendValidatingKey = fvk.clone().into();
695    let ivk = external_ivk_scalar(fvk, &ak);
696
697    // Derive the nullifier domain for this round (ZIP §Nullifier Domains).
698    let dom = derive_nullifier_domain(vote_round_id);
699
700    // Collect per-note data.
701    let mut note_slots = Vec::with_capacity(circuit::MAX_REAL_NOTES);
702    let mut cmx_values = Vec::with_capacity(circuit::MAX_REAL_NOTES);
703    let mut v_values = Vec::with_capacity(circuit::MAX_REAL_NOTES);
704    let mut gov_nulls = Vec::with_capacity(circuit::MAX_REAL_NOTES);
705
706    // Process real notes: derive psi/rcm from rseed, compute the note commitment,
707    // real nullifier, and gov nullifier, then pack everything into a NoteSlotWitness.
708    for input in &real_notes {
709        let note = &input.note;
710        let rho = note.rho();
711        let psi = note.rseed().psi(&rho);
712        let rcm = note.rseed().rcm(&rho);
713        let cm = note.commitment();
714        let cmx = ExtractedNoteCommitment::from(cm.clone()).inner();
715        let v_raw = note.value().inner();
716        let recipient = note.recipient();
717
718        // Condition 12: real nullifier for IMT non-membership.
719        let real_nf = note.nullifier(fvk);
720        // Condition 14: alternate nullifier = Poseidon(nk, dom, real_nf).
721        let gov_null = gov_null_hash(nk_val, dom, real_nf.inner());
722
723        let slot = NoteSlotWitness {
724            g_d: Value::known(recipient.g_d()),
725            pk_d: Value::known(recipient.pk_d().inner()),
726            v: Value::known(note.value()),
727            rho: Value::known(rho.into_inner()),
728            psi: Value::known(psi),
729            rcm: Value::known(rcm),
730            cm: Value::known(cm.inner().to_affine()),
731            path: Value::known(input.merkle_path.auth_path()),
732            pos: Value::known(input.merkle_path.position()),
733            imt_nf_bounds: Value::known(input.imt_proof.nf_bounds),
734            imt_leaf_pos: Value::known(input.imt_proof.leaf_pos),
735            imt_path: Value::known(input.imt_proof.path),
736            is_internal: Value::known(matches!(input.scope, Scope::Internal)),
737        };
738
739        note_slots.push(slot);
740        cmx_values.push(cmx);
741        v_values.push(v_raw);
742        gov_nulls.push(gov_null);
743    }
744
745    // Pad remaining slots with zero-value dummy notes (ZIP §Note Padding).
746    // Dummy notes use v=0, which gates condition 10 (Merkle path) via
747    // v * (root - anchor) = 0. All other conditions run unconditionally.
748    for i in n_real..circuit::MAX_REAL_NOTES {
749        let pad_idx = i - n_real; // index into precomputed.padded_notes
750        let padding =
751            build_padding_slot(i, pad_idx, nk_val, dom, ivk, imt_provider, rng, precomputed)?;
752
753        note_slots.push(padding.witness);
754        cmx_values.push(padding.cmx);
755        v_values.push(padding.v_raw);
756        gov_nulls.push(padding.gov_null);
757    }
758
759    let notes: [NoteSlotWitness; circuit::MAX_REAL_NOTES] =
760        note_slots.try_into().unwrap_or_else(|_| unreachable!());
761
762    // Condition 8: ballot scaling.
763    // num_ballots = floor(v_total / BALLOT_DIVISOR)
764    let v_total_u64: u64 = v_values.iter().sum();
765    let num_ballots_u64 = v_total_u64 / BALLOT_DIVISOR;
766    let remainder_u64 = v_total_u64 % BALLOT_DIVISOR;
767    let num_ballots_field = pallas::Base::from(num_ballots_u64);
768
769    // Condition 7: gov commitment integrity.
770    // van_comm = Poseidon(DOMAIN_VAN, g_d_new_x, pk_d_new_x, num_ballots,
771    //                     vote_round_id, MAX_PROPOSAL_AUTHORITY, van_comm_rand)
772    // Extract the output address as two x-coordinates (vpk representation).
773
774    let g_d_new_x = *output_recipient
775        .g_d()
776        .to_affine()
777        .coordinates()
778        .unwrap()
779        .x();
780    let pk_d_new_x = *output_recipient
781        .pk_d()
782        .inner()
783        .to_affine()
784        .coordinates()
785        .unwrap()
786        .x();
787
788    let van_comm = van_commitment_hash(
789        g_d_new_x,
790        pk_d_new_x,
791        num_ballots_field,
792        vote_round_id,
793        van_comm_rand,
794    );
795
796    // Condition 3: rho binding.
797    // rho_signed = Poseidon(cmx_1, cmx_2, cmx_3, cmx_4, cmx_5, van_comm, vote_round_id)
798    // Binds the keystone note to the exact notes being delegated.
799    let rho = rho_binding_hash(
800        cmx_values[0],
801        cmx_values[1],
802        cmx_values[2],
803        cmx_values[3],
804        cmx_values[4],
805        van_comm,
806        vote_round_id,
807    );
808
809    // Construct the keystone (signed) note (ZIP §Dummy Signed Note).
810    // Value is 1 so that hardware wallets (Keystone) render the transaction.
811    // The rho is bound to the delegation via condition 3.
812    let sender_address = fvk.address_at(0u32, Scope::External);
813    let signed_rho = Rho::from_nf_old(Nullifier::from_inner(rho));
814    let signed_note = if let Some(pre) = precomputed {
815        let location = PrecomputedRandomnessLocation::SignedNote;
816        let rseed = RandomSeed::from_bytes(pre.rseed_signed, &signed_rho)
817            .into_option()
818            .ok_or(DelegationBuildError::InvalidPrecomputedRseed { location })?;
819        Note::from_parts(sender_address, NoteValue::from_raw(1), signed_rho, rseed)
820            .into_option()
821            .ok_or(DelegationBuildError::InvalidPrecomputedNote { location })?
822    } else {
823        Note::new(
824            sender_address,
825            NoteValue::from_raw(1),
826            signed_rho,
827            &mut *rng,
828        )
829    };
830
831    // Condition 2: nullifier integrity — nf_signed is a public input.
832    let nf_signed = signed_note.nullifier(fvk);
833
834    // Condition 6: output note commitment integrity.
835    // The output note is sent to the voting hotkey address with rho = nf_signed.
836    let output_rho = Rho::from_nf_old(nf_signed);
837    let output_note = if let Some(pre) = precomputed {
838        let location = PrecomputedRandomnessLocation::OutputNote;
839        let rseed = RandomSeed::from_bytes(pre.rseed_output, &output_rho)
840            .into_option()
841            .ok_or(DelegationBuildError::InvalidPrecomputedRseed { location })?;
842        Note::from_parts(output_recipient, NoteValue::ZERO, output_rho, rseed)
843            .into_option()
844            .ok_or(DelegationBuildError::InvalidPrecomputedNote { location })?
845    } else {
846        Note::new(output_recipient, NoteValue::ZERO, output_rho, &mut *rng)
847    };
848    let cmx_new = ExtractedNoteCommitment::from(output_note.commitment()).inner();
849
850    // Condition 4: spend authority — rk is the randomized spend key.
851    let rk = ak.randomize(&alpha);
852
853    // Assemble the circuit (private witnesses) and instance (public inputs).
854    // The caller runs keygen + create_proof on the circuit, then submits
855    // the proof + instance to the vote chain. The verifier only needs
856    // the instance, proof, and verification key.
857    let circuit = circuit::Circuit::from_note_unchecked(fvk, &signed_note, alpha)
858        .with_output_note(&output_note)
859        .with_notes(notes)
860        .with_van_comm_rand(van_comm_rand)
861        .with_ballot_scaling(
862            pallas::Base::from(num_ballots_u64),
863            pallas::Base::from(remainder_u64),
864        );
865
866    let instance = circuit::Instance::from_parts(
867        nf_signed,
868        rk,
869        cmx_new,
870        van_comm,
871        vote_round_id,
872        nc_root,
873        nf_imt_root,
874        [
875            gov_nulls[0],
876            gov_nulls[1],
877            gov_nulls[2],
878            gov_nulls[3],
879            gov_nulls[4],
880        ],
881        dom,
882    )?;
883
884    Ok(DelegationBundle { circuit, instance })
885}
886
887// ================================================================
888// Test-only
889// ================================================================
890
891#[cfg(test)]
892mod tests {
893    use super::*;
894    use crate::delegation::imt::{ImtError, SpacedLeafImtProvider};
895    use ff::Field;
896    use halo2_proofs::dev::MockProver;
897    use incrementalmerkletree::{Hashable, Level};
898    use orchard::{
899        constants::MERKLE_DEPTH_ORCHARD,
900        keys::{FullViewingKey, Scope, SpendingKey},
901        note::{commitment::ExtractedNoteCommitment, Note, Rho},
902        tree::{MerkleHashOrchard, MerklePath},
903        value::NoteValue,
904    };
905    use pasta_curves::pallas;
906    use rand::rngs::OsRng;
907    use std::cell::{Cell, RefCell};
908
909    /// Merged circuit K value.
910    const K: u32 = 14;
911
912    #[derive(Debug)]
913    struct RecordingImtProvider {
914        proof: ImtProofData,
915        error: Option<ImtError>,
916        requested_nfs: RefCell<Vec<pallas::Base>>,
917    }
918
919    impl RecordingImtProvider {
920        fn returning(proof: ImtProofData) -> Self {
921            Self {
922                proof,
923                error: None,
924                requested_nfs: RefCell::new(Vec::new()),
925            }
926        }
927
928        fn failing(error: ImtError) -> Self {
929            Self {
930                proof: test_imt_proof(),
931                error: Some(error),
932                requested_nfs: RefCell::new(Vec::new()),
933            }
934        }
935    }
936
937    impl ImtProvider for RecordingImtProvider {
938        fn root(&self) -> pallas::Base {
939            self.proof.root
940        }
941
942        fn non_membership_proof(&self, nf: pallas::Base) -> Result<ImtProofData, ImtError> {
943            self.requested_nfs.borrow_mut().push(nf);
944            match &self.error {
945                Some(error) => Err(error.clone()),
946                None => Ok(self.proof.clone()),
947            }
948        }
949    }
950
951    fn test_imt_proof() -> ImtProofData {
952        ImtProofData {
953            root: pallas::Base::from(900u64),
954            nf_bounds: [
955                pallas::Base::from(10u64),
956                pallas::Base::from(20u64),
957                pallas::Base::from(30u64),
958            ],
959            leaf_pos: 7,
960            path: std::array::from_fn(|i| pallas::Base::from(1_000u64 + i as u64)),
961        }
962    }
963
964    fn precomputed_padding_note(rng: &mut impl RngCore) -> (PaddedNoteData, Rho, RandomSeed) {
965        let rho = Rho::from_nf_old(Nullifier::from_inner(pallas::Base::random(&mut *rng)));
966        let rseed = random_seed_for_rho(&rho, &mut *rng);
967        (
968            PaddedNoteData {
969                rho: rho.to_bytes(),
970                rseed: *rseed.as_bytes(),
971            },
972            rho,
973            rseed,
974        )
975    }
976
977    fn assert_known<T>(value: &Value<T>, f: impl FnOnce(&T) -> bool) {
978        let checked = Cell::new(false);
979        value.assert_if_known(|actual| {
980            checked.set(true);
981            f(actual)
982        });
983        assert!(checked.get(), "expected known witness value");
984    }
985
986    fn assert_padding_slot_matches(
987        padding: &PaddingSlot,
988        slot_index: usize,
989        nk: pallas::Base,
990        dom: pallas::Base,
991        ivk: pallas::Scalar,
992        rho: Rho,
993        rseed: RandomSeed,
994        imt_proof: &ImtProofData,
995        requested_nfs: &[pallas::Base],
996    ) {
997        let (g_d_pad, pk_d_pad) =
998            padding_points(slot_index, ivk).expect("test padding points should be valid");
999        let psi = rseed.psi(&rho);
1000        let rcm = rseed.rcm(&rho);
1001        let cm = note_commitment_point(
1002            *g_d_pad,
1003            *pk_d_pad,
1004            NoteValue::ZERO,
1005            rho.into_inner(),
1006            psi,
1007            rcm.inner(),
1008        )
1009        .expect("test padding commitment should be valid")
1010        .to_affine();
1011        let real_nf = derive_note_nullifier(nk, rho.into_inner(), psi, cm)
1012            .expect("test padding nullifier should be valid");
1013
1014        assert_eq!(padding.cmx, *cm.coordinates().unwrap().x());
1015        assert_eq!(padding.v_raw, 0);
1016        assert_eq!(padding.gov_null, gov_null_hash(nk, dom, real_nf));
1017        assert_eq!(requested_nfs, &[real_nf]);
1018
1019        assert_known(&padding.witness.g_d, |actual| *actual == g_d_pad);
1020        assert_known(&padding.witness.pk_d, |actual| *actual == pk_d_pad);
1021        assert_known(&padding.witness.v, |actual| *actual == NoteValue::ZERO);
1022        assert_known(&padding.witness.rho, |actual| *actual == rho.into_inner());
1023        assert_known(&padding.witness.psi, |actual| *actual == psi);
1024        assert_known(&padding.witness.rcm, |actual| actual.inner() == rcm.inner());
1025        assert_known(&padding.witness.cm, |actual| *actual == cm);
1026        assert_known(&padding.witness.imt_nf_bounds, |actual| {
1027            *actual == imt_proof.nf_bounds
1028        });
1029        assert_known(&padding.witness.imt_leaf_pos, |actual| {
1030            *actual == imt_proof.leaf_pos
1031        });
1032        assert_known(&padding.witness.imt_path, |actual| {
1033            *actual == imt_proof.path
1034        });
1035        assert_known(&padding.witness.is_internal, |actual| !*actual);
1036    }
1037
1038    /// Helper: create 1 to `circuit::MAX_REAL_NOTES` real note inputs with a
1039    /// shared Merkle tree and anchor.
1040    ///
1041    /// Notes are placed at positions 0..n in the commitment tree. Returns
1042    /// `(inputs, nc_root)` where `nc_root` is the shared anchor.
1043    ///
1044    fn make_real_note_inputs(
1045        fvk: &FullViewingKey,
1046        values: &[u64],
1047        scopes: &[Scope],
1048        imt_provider: &impl ImtProvider,
1049        rng: &mut impl RngCore,
1050    ) -> (Vec<RealNoteInput>, pallas::Base) {
1051        let n = values.len();
1052        assert!((1..=circuit::MAX_REAL_NOTES).contains(&n));
1053        assert_eq!(n, scopes.len());
1054
1055        // Create notes.
1056        let mut notes = Vec::with_capacity(n);
1057        for (idx, &v) in values.iter().enumerate() {
1058            let recipient = fvk.address_at(0u32, scopes[idx]);
1059            let note_value = NoteValue::from_raw(v);
1060            let (_, _, dummy_parent) = Note::dummy(&mut *rng, None);
1061            let note = Note::new(
1062                recipient,
1063                note_value,
1064                Rho::from_nf_old(dummy_parent.nullifier(fvk)),
1065                &mut *rng,
1066            );
1067            notes.push(note);
1068        }
1069
1070        // Extract leaf hashes, padding to 8 with empty leaves.
1071        let empty_leaf = MerkleHashOrchard::empty_leaf();
1072        let mut leaves = [empty_leaf; 8];
1073        for (i, note) in notes.iter().enumerate() {
1074            let cmx = ExtractedNoteCommitment::from(note.commitment());
1075            leaves[i] = MerkleHashOrchard::from_cmx(&cmx);
1076        }
1077
1078        // Build the bottom three levels of the shared tree (8-leaf tree).
1079        let l1_0 = MerkleHashOrchard::combine(Level::from(0), &leaves[0], &leaves[1]);
1080        let l1_1 = MerkleHashOrchard::combine(Level::from(0), &leaves[2], &leaves[3]);
1081        let l1_2 = MerkleHashOrchard::combine(Level::from(0), &leaves[4], &leaves[5]);
1082        let l1_3 = MerkleHashOrchard::combine(Level::from(0), &leaves[6], &leaves[7]);
1083        let l2_0 = MerkleHashOrchard::combine(Level::from(1), &l1_0, &l1_1);
1084        let l2_1 = MerkleHashOrchard::combine(Level::from(1), &l1_2, &l1_3);
1085        let l3_0 = MerkleHashOrchard::combine(Level::from(2), &l2_0, &l2_1);
1086
1087        // Hash up through the remaining levels with empty subtree siblings.
1088        let mut current = l3_0;
1089        for level in 3..MERKLE_DEPTH_ORCHARD {
1090            let sibling = MerkleHashOrchard::empty_root(Level::from(level as u8));
1091            current = MerkleHashOrchard::combine(Level::from(level as u8), &current, &sibling);
1092        }
1093        let nc_root = current.inner();
1094
1095        // Build Merkle paths and RealNoteInputs.
1096        let l1 = [l1_0, l1_1, l1_2, l1_3];
1097        let l2 = [l2_0, l2_1];
1098        let mut inputs = Vec::with_capacity(n);
1099        for (i, note) in notes.into_iter().enumerate() {
1100            let mut auth_path = [MerkleHashOrchard::empty_leaf(); MERKLE_DEPTH_ORCHARD];
1101            auth_path[0] = leaves[i ^ 1];
1102            auth_path[1] = l1[(i >> 1) ^ 1];
1103            auth_path[2] = l2[1 - (i >> 2)];
1104            for level in 3..MERKLE_DEPTH_ORCHARD {
1105                auth_path[level] = MerkleHashOrchard::empty_root(Level::from(level as u8));
1106            }
1107            let merkle_path = MerklePath::from_parts(i as u32, auth_path);
1108
1109            let real_nf = note.nullifier(fvk);
1110            let imt_proof = imt_provider.non_membership_proof(real_nf.inner()).unwrap();
1111
1112            inputs.push(RealNoteInput {
1113                note,
1114                fvk: fvk.clone(),
1115                merkle_path,
1116                imt_proof,
1117                scope: scopes[i],
1118            });
1119        }
1120
1121        (inputs, nc_root)
1122    }
1123
1124    /// Helper: build a bundle with explicit scopes.
1125    fn build_bundle(values: &[u64], scopes: &[Scope]) -> DelegationBundle {
1126        assert_eq!(values.len(), scopes.len());
1127        let mut rng = OsRng;
1128        let sk = SpendingKey::random(&mut rng);
1129        let fvk: FullViewingKey = (&sk).into();
1130        let output_recipient = fvk.address_at(1u32, Scope::External);
1131        let vote_round_id = pallas::Base::random(&mut rng);
1132        let van_comm_rand = pallas::Base::random(&mut rng);
1133        let alpha = pallas::Scalar::random(&mut rng);
1134
1135        let imt = SpacedLeafImtProvider::new();
1136        let (inputs, nc_root) = make_real_note_inputs(&fvk, values, scopes, &imt, &mut rng);
1137
1138        let bundle = build_delegation_bundle(
1139            inputs,
1140            &fvk,
1141            alpha,
1142            output_recipient,
1143            vote_round_id,
1144            nc_root,
1145            van_comm_rand,
1146            &imt,
1147            &mut rng,
1148            None,
1149        )
1150        .unwrap();
1151
1152        assert_delegation_output_shape(&bundle);
1153        bundle
1154    }
1155
1156    fn build_single_note_bundle_with_precomputed(
1157        precomputed: &PrecomputedRandomness,
1158    ) -> Result<DelegationBundle, DelegationBuildError> {
1159        let mut rng = OsRng;
1160        let sk = SpendingKey::random(&mut rng);
1161        let fvk: FullViewingKey = (&sk).into();
1162
1163        build_single_note_bundle_with_fvk_and_precomputed(&fvk, precomputed, &mut rng)
1164    }
1165
1166    fn build_single_note_bundle_with_fvk_and_precomputed(
1167        fvk: &FullViewingKey,
1168        precomputed: &PrecomputedRandomness,
1169        rng: &mut (impl RngCore + CryptoRng),
1170    ) -> Result<DelegationBundle, DelegationBuildError> {
1171        let output_recipient = fvk.address_at(1u32, Scope::External);
1172        let vote_round_id = pallas::Base::random(&mut *rng);
1173        let van_comm_rand = pallas::Base::random(&mut *rng);
1174        let alpha = pallas::Scalar::random(&mut *rng);
1175
1176        let imt = SpacedLeafImtProvider::new();
1177        let (inputs, nc_root) =
1178            make_real_note_inputs(fvk, &[13_000_000], &[Scope::External], &imt, &mut *rng);
1179
1180        build_delegation_bundle(
1181            inputs,
1182            fvk,
1183            alpha,
1184            output_recipient,
1185            vote_round_id,
1186            nc_root,
1187            van_comm_rand,
1188            &imt,
1189            rng,
1190            Some(precomputed),
1191        )
1192    }
1193
1194    fn make_valid_padded_note_data(rng: &mut impl RngCore) -> PaddedNoteData {
1195        let rho = Rho::from_nf_old(Nullifier::from_inner(pallas::Base::random(&mut *rng)));
1196        let rseed = random_seed_for_rho(&rho, rng);
1197
1198        PaddedNoteData {
1199            rho: rho.to_bytes(),
1200            rseed: *rseed.as_bytes(),
1201        }
1202    }
1203
1204    fn assert_delegation_output_shape(bundle: &DelegationBundle) {
1205        let pi = bundle.instance.to_halo2_instance();
1206        assert_eq!(pi.len(), 14, "delegation public input shape changed");
1207        assert_eq!(bundle.instance.gov_null.len(), 5);
1208        assert_eq!(pi[0], bundle.instance.nf_signed.inner());
1209        assert_eq!(pi[3], bundle.instance.cmx_new);
1210        assert_eq!(pi[4], bundle.instance.van_comm);
1211        assert_eq!(pi[5], bundle.instance.vote_round_id);
1212        assert_eq!(pi[6], bundle.instance.nc_root);
1213        assert_eq!(pi[7], bundle.instance.nf_imt_root);
1214        assert_eq!(&pi[8..13], &bundle.instance.gov_null);
1215        assert_eq!(pi[13], bundle.instance.dom);
1216    }
1217
1218    fn verify_bundle(bundle: &DelegationBundle) {
1219        // Verify merged circuit.
1220        let pi = bundle.instance.to_halo2_instance();
1221        let prover = MockProver::run(K, &bundle.circuit, vec![pi]).unwrap();
1222        assert_eq!(prover.verify(), Ok(()), "merged circuit failed");
1223    }
1224
1225    #[test]
1226    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1227    fn test_single_real_note() {
1228        let bundle = build_bundle(&[13_000_000], &[Scope::External]);
1229        verify_bundle(&bundle);
1230    }
1231
1232    /// Build a bundle without verifying so callers can inspect the circuit
1233    /// witnesses. Mirrors `build_and_verify` minus the MockProver step and
1234    /// returns `(bundle, fvk, ak)` so the test can recompute the external IVK.
1235    fn build_bundle_for_inspection(
1236        values: &[u64],
1237        scopes: &[Scope],
1238    ) -> (DelegationBundle, FullViewingKey, SpendValidatingKey) {
1239        let mut rng = OsRng;
1240        let sk = SpendingKey::random(&mut rng);
1241        let fvk: FullViewingKey = (&sk).into();
1242        let ak: SpendValidatingKey = fvk.clone().into();
1243        let output_recipient = fvk.address_at(1u32, Scope::External);
1244        let vote_round_id = pallas::Base::random(&mut rng);
1245        let van_comm_rand = pallas::Base::random(&mut rng);
1246        let alpha = pallas::Scalar::random(&mut rng);
1247        let imt = SpacedLeafImtProvider::new();
1248        let (inputs, nc_root) = make_real_note_inputs(&fvk, values, scopes, &imt, &mut rng);
1249
1250        let bundle = build_delegation_bundle(
1251            inputs,
1252            &fvk,
1253            alpha,
1254            output_recipient,
1255            vote_round_id,
1256            nc_root,
1257            van_comm_rand,
1258            &imt,
1259            &mut rng,
1260            None,
1261        )
1262        .unwrap();
1263        (bundle, fvk, ak)
1264    }
1265
1266    #[test]
1267    fn test_single_real_note_locks_padding_witnesses() {
1268        // With 1 real note, slots 1..5 must be padding and their (g_d, pk_d)
1269        // must come from `padding_points(slot_index, external_ivk_scalar(...))`.
1270        // Catches regressions where the synthetic padding path is silently
1271        // replaced (e.g. a fallback to `fvk.address_at(...)`) or where the
1272        // slot index passed to `padding_points` skews off-by-one.
1273        let (bundle, fvk, ak) = build_bundle_for_inspection(&[13_000_000], &[Scope::External]);
1274        let ivk = external_ivk_scalar(&fvk, &ak);
1275        let notes = bundle.circuit.notes_for_testing();
1276        for slot_index in 1..5 {
1277            let (expected_g_d, expected_pk_d) =
1278                padding_points(slot_index, ivk).expect("test padding points should be valid");
1279            assert_known(&notes[slot_index].g_d, |actual| *actual == expected_g_d);
1280            assert_known(&notes[slot_index].pk_d, |actual| *actual == expected_pk_d);
1281        }
1282    }
1283
1284    #[test]
1285    fn test_five_real_notes_uses_no_padding() {
1286        // With 5 real notes there are no padding slots. Assert that no slot's
1287        // g_d matches the synthetic padding point for any valid padding slot — this
1288        // catches an off-by-one in the `n_real..5` iteration boundary that
1289        // would smuggle a padding point into a real slot (which would silently
1290        // zero that slot's vote weight in condition 10's `v * (root - anchor)`
1291        // gate path because padding always has `v = 0`).
1292        let (bundle, fvk, ak) = build_bundle_for_inspection(&[2_500_000; 5], &[Scope::External; 5]);
1293        let ivk = external_ivk_scalar(&fvk, &ak);
1294        let padding_g_ds: Vec<_> = (1..circuit::MAX_REAL_NOTES)
1295            .map(|i| {
1296                padding_points(i, ivk)
1297                    .expect("test padding points should be valid")
1298                    .0
1299            })
1300            .collect();
1301        let notes = bundle.circuit.notes_for_testing();
1302        for slot_index in 0..5 {
1303            for pad_g_d in &padding_g_ds {
1304                assert_known(&notes[slot_index].g_d, |actual| actual != pad_g_d);
1305            }
1306        }
1307    }
1308
1309    #[test]
1310    fn test_padding_points_are_synthetic_and_ivk_bound() {
1311        let mut rng = OsRng;
1312        let sk = SpendingKey::random(&mut rng);
1313        let fvk: FullViewingKey = (&sk).into();
1314        let ak: SpendValidatingKey = fvk.clone().into();
1315        let ivk = external_ivk_scalar(&fvk, &ak);
1316
1317        for slot_index in 1..5 {
1318            let (g_d_pad, pk_d_pad) =
1319                padding_points(slot_index, ivk).expect("test padding points should be valid");
1320            let real_orchard_addr = fvk.address_at(slot_index as u32, Scope::External);
1321
1322            // Deref `NonIdentityPallasPoint` to `pallas::Point` for arithmetic and
1323            // coordinate access; the wrapper enforces the non-identity invariant
1324            // at construction (`padding_points` -> `assert_non_identity`).
1325            assert_eq!(*pk_d_pad, *g_d_pad * ivk);
1326            assert_ne!(*g_d_pad, *real_orchard_addr.g_d());
1327            assert_ne!(*pk_d_pad, *real_orchard_addr.pk_d().inner());
1328        }
1329    }
1330
1331    #[test]
1332    fn test_padding_points_reject_impossible_slot_indices() {
1333        let mut rng = OsRng;
1334        let sk = SpendingKey::random(&mut rng);
1335        let fvk: FullViewingKey = (&sk).into();
1336        let ak: SpendValidatingKey = fvk.clone().into();
1337        let ivk = external_ivk_scalar(&fvk, &ak);
1338
1339        for slot_index in [0, circuit::MAX_REAL_NOTES, usize::MAX] {
1340            assert!(matches!(
1341                padding_points(slot_index, ivk),
1342                Err(DelegationBuildError::InvalidPaddingSlotIndex { slot_index: actual })
1343                    if actual == slot_index
1344            ));
1345        }
1346    }
1347
1348    /// Locks the structural property that makes ZCA-450's fix correct: the
1349    /// hash-to-curve personalization for padding `g_d_pad` is **different**
1350    /// from Orchard's `KEY_DIVERSIFICATION_PERSONALIZATION`. Domain separation
1351    /// keeps padding out of Orchard's ordinary diversifier-index derivation; a
1352    /// point collision with `DiversifyHash(d)` would be treated as a negligible
1353    /// hash-to-curve collision rather than a deterministic address-universe
1354    /// overlap. If either constant is ever renamed (here or upstream) so they
1355    /// coincide, this test fails loudly before a release ships a padding scheme
1356    /// that re-enters the real-Orchard derivation path.
1357    ///
1358    /// Exhaustive testing of the inverse property — that no 88-bit `d`
1359    /// satisfies `DiversifyHash(d) == g_d_pad_i` — is infeasible (2^88
1360    /// preimage search), so we lock the construction-time invariant (different
1361    /// personalization) rather than claiming a formal no-collision invariant.
1362    #[test]
1363    fn test_padding_personalization_is_domain_separated_from_orchard() {
1364        use orchard::constants::KEY_DIVERSIFICATION_PERSONALIZATION;
1365
1366        assert_ne!(
1367            PADDING_PERSONALIZATION, KEY_DIVERSIFICATION_PERSONALIZATION,
1368            "padding personalization must be domain-separated from Orchard's \
1369             DiversifyHash personalization; otherwise synthetic padding `g_d_pad` \
1370             can collide with real diversified bases and ZCA-450's fix regresses"
1371        );
1372    }
1373
1374    // ---- Orchard drift tests ----
1375    //
1376    // `note_commitment_point`, `derive_note_nullifier`, and `external_ivk_scalar`
1377    // mirror internal Orchard logic bit-for-bit because Orchard does not expose
1378    // the corresponding low-level helpers publicly. If Orchard ever changes the
1379    // bit-encoding, domain personalization, scope handling, or any other input
1380    // to these primitives, the mirrored helpers in this module will silently
1381    // desync from in-circuit `NoteCommit` / `DeriveNullifier` / `CommitIvk`
1382    // (which still consume Orchard's gadgets via `note_commit`, `derive_nullifier`,
1383    // and `prove_address_ownership`). These tests use real Orchard `Note` /
1384    // `FullViewingKey` instances as the source of truth and fail loudly on drift.
1385
1386    /// Builds a real Orchard `Note` for use as ground truth in drift tests.
1387    fn fixture_real_note(
1388        scope: Scope,
1389        rng: &mut impl RngCore,
1390    ) -> (FullViewingKey, SpendValidatingKey, Note) {
1391        let sk = SpendingKey::random(rng);
1392        let fvk: FullViewingKey = (&sk).into();
1393        let ak: SpendValidatingKey = fvk.clone().into();
1394        let recipient = fvk.address_at(0u32, scope);
1395        let (_, _, dummy_parent) = Note::dummy(rng, None);
1396        let note = Note::new(
1397            recipient,
1398            NoteValue::from_raw(12_500_000),
1399            Rho::from_nf_old(dummy_parent.nullifier(&fvk)),
1400            rng,
1401        );
1402        (fvk, ak, note)
1403    }
1404
1405    #[test]
1406    fn test_note_commitment_point_matches_orchard() {
1407        // Locks `note_commitment_point` to Orchard's `NoteCommitment::derive`.
1408        // The builder uses this helper for padding slots (where g_d/pk_d are
1409        // synthetic), so any drift in the underlying bit encoding would yield
1410        // padding commitments that the in-circuit NoteCommit (cond 9) rejects.
1411        let mut rng = OsRng;
1412        for scope in [Scope::External, Scope::Internal] {
1413            let (_fvk, _ak, note) = fixture_real_note(scope, &mut rng);
1414            let recipient = note.recipient();
1415            let rho = note.rho();
1416            let psi = note.rseed().psi(&rho);
1417            let rcm = note.rseed().rcm(&rho);
1418
1419            let mirrored = note_commitment_point(
1420                *recipient.g_d(),
1421                *recipient.pk_d().inner(),
1422                note.value(),
1423                rho.into_inner(),
1424                psi,
1425                rcm.inner(),
1426            );
1427            let orchard = note.commitment().inner();
1428
1429            assert_eq!(
1430                mirrored,
1431                Some(orchard),
1432                "note_commitment_point drifted from Orchard NoteCommitment::derive ({scope:?})"
1433            );
1434        }
1435    }
1436
1437    #[test]
1438    fn test_derive_note_nullifier_matches_orchard() {
1439        // Locks `derive_note_nullifier` to Orchard's `Nullifier::derive`. The
1440        // builder uses this for padding slots; drift would produce padding
1441        // nullifiers that disagree with in-circuit `DeriveNullifier` (cond 12)
1442        // and break the IMT non-membership / gov-null derivation (conds 13, 14).
1443        let mut rng = OsRng;
1444        for scope in [Scope::External, Scope::Internal] {
1445            let (fvk, _ak, note) = fixture_real_note(scope, &mut rng);
1446            let nk = fvk.nk().inner();
1447            let rho = note.rho();
1448            let psi = note.rseed().psi(&rho);
1449            let cm = note.commitment().inner().to_affine();
1450
1451            let mirrored = derive_note_nullifier(nk, rho.into_inner(), psi, cm);
1452            let orchard = note.nullifier(&fvk).inner();
1453
1454            assert_eq!(
1455                mirrored,
1456                Some(orchard),
1457                "derive_note_nullifier drifted from Orchard Nullifier::derive ({scope:?})"
1458            );
1459        }
1460    }
1461
1462    #[test]
1463    fn test_external_ivk_scalar_matches_orchard_address_derivation() {
1464        // Orchard does not expose `IncomingViewingKey`'s inner scalar, so we
1465        // check the IVK indirectly via the diversified-address invariant
1466        // `pk_d = [ivk_external] * g_d` on a real external-scope Orchard
1467        // address. If `external_ivk_scalar` drifts (wrong personalization,
1468        // wrong bit-take, wrong rivk scope, missing base_to_scalar), this
1469        // equation will fail and so will in-circuit condition 11 for padding.
1470        let mut rng = OsRng;
1471        let sk = SpendingKey::random(&mut rng);
1472        let fvk: FullViewingKey = (&sk).into();
1473        let ak: SpendValidatingKey = fvk.clone().into();
1474        let ivk = external_ivk_scalar(&fvk, &ak);
1475
1476        // Sweep several diversifier indices to catch accidental fixed-index
1477        // shortcuts in the derivation.
1478        for idx in [0u32, 1, 7, 1234] {
1479            let addr = fvk.address_at(idx, Scope::External);
1480            assert_eq!(
1481                *addr.g_d() * ivk,
1482                *addr.pk_d().inner(),
1483                "external_ivk_scalar drifted: [ivk] * g_d != pk_d at diversifier index {idx}"
1484            );
1485        }
1486
1487        // Sanity: the external ivk must NOT validate internal-scope addresses,
1488        // catching a bug where `rivk(Scope::External)` is silently swapped for
1489        // `rivk(Scope::Internal)`.
1490        let internal_addr = fvk.address_at(0u32, Scope::Internal);
1491        assert_ne!(
1492            *internal_addr.g_d() * ivk,
1493            *internal_addr.pk_d().inner(),
1494            "external_ivk_scalar incorrectly validates an internal-scope address"
1495        );
1496    }
1497
1498    #[test]
1499    fn test_build_padding_slot_fresh_randomness_populates_strict_witnesses() {
1500        let mut rng = OsRng;
1501        let sk = SpendingKey::random(&mut rng);
1502        let fvk: FullViewingKey = (&sk).into();
1503        let ak: SpendValidatingKey = fvk.clone().into();
1504        let nk = fvk.nk().inner();
1505        let dom = derive_nullifier_domain(pallas::Base::random(&mut rng));
1506        let ivk = external_ivk_scalar(&fvk, &ak);
1507        let imt_proof = test_imt_proof();
1508        let imt = RecordingImtProvider::returning(imt_proof.clone());
1509
1510        let padding = build_padding_slot(3, 0, nk, dom, ivk, &imt, &mut rng, None).unwrap();
1511
1512        let (g_d_pad, pk_d_pad) =
1513            padding_points(3, ivk).expect("test padding points should be valid");
1514        assert_known(&padding.witness.g_d, |actual| *actual == g_d_pad);
1515        assert_known(&padding.witness.pk_d, |actual| *actual == pk_d_pad);
1516        let generated_padding_values = padding
1517            .witness
1518            .rho
1519            .as_ref()
1520            .copied()
1521            .zip(padding.witness.psi.as_ref().copied())
1522            .zip(padding.witness.rcm.as_ref().cloned())
1523            .zip(padding.witness.cm.as_ref().copied());
1524        assert_known(
1525            &generated_padding_values,
1526            |(((rho_inner, psi), rcm), cm_witness)| {
1527                let cm = note_commitment_point(
1528                    *g_d_pad,
1529                    *pk_d_pad,
1530                    NoteValue::ZERO,
1531                    *rho_inner,
1532                    *psi,
1533                    rcm.inner(),
1534                )
1535                .expect("test padding commitment should be valid")
1536                .to_affine();
1537                let real_nf = derive_note_nullifier(nk, *rho_inner, *psi, cm)
1538                    .expect("test padding nullifier should be valid");
1539
1540                *cm_witness == cm
1541                    && padding.cmx == *cm.coordinates().unwrap().x()
1542                    && padding.gov_null == gov_null_hash(nk, dom, real_nf)
1543                    && imt.requested_nfs.borrow().as_slice() == [real_nf]
1544            },
1545        );
1546        assert_known(&padding.witness.v, |actual| *actual == NoteValue::ZERO);
1547        assert_known(&padding.witness.is_internal, |actual| !*actual);
1548        assert_known(&padding.witness.imt_nf_bounds, |actual| {
1549            *actual == imt_proof.nf_bounds
1550        });
1551        assert_known(&padding.witness.imt_leaf_pos, |actual| {
1552            *actual == imt_proof.leaf_pos
1553        });
1554        assert_known(&padding.witness.imt_path, |actual| {
1555            *actual == imt_proof.path
1556        });
1557        assert_eq!(padding.v_raw, 0);
1558        assert_eq!(imt.requested_nfs.borrow().len(), 1);
1559    }
1560
1561    #[test]
1562    fn test_build_padding_slot_reuses_selected_precomputed_randomness() {
1563        let mut rng = OsRng;
1564        let sk = SpendingKey::random(&mut rng);
1565        let fvk: FullViewingKey = (&sk).into();
1566        let ak: SpendValidatingKey = fvk.clone().into();
1567        let nk = fvk.nk().inner();
1568        let dom = derive_nullifier_domain(pallas::Base::random(&mut rng));
1569        let ivk = external_ivk_scalar(&fvk, &ak);
1570        let imt_proof = test_imt_proof();
1571        let imt = RecordingImtProvider::returning(imt_proof.clone());
1572
1573        let (unused_pd, _, _) = precomputed_padding_note(&mut rng);
1574        let (selected_pd, selected_rho, selected_rseed) = precomputed_padding_note(&mut rng);
1575        let precomputed = PrecomputedRandomness {
1576            padded_notes: vec![unused_pd, selected_pd],
1577            rseed_signed: [0; 32],
1578            rseed_output: [0; 32],
1579        };
1580
1581        let padding =
1582            build_padding_slot(4, 1, nk, dom, ivk, &imt, &mut rng, Some(&precomputed)).unwrap();
1583
1584        let requested_nfs = imt.requested_nfs.borrow();
1585        assert_padding_slot_matches(
1586            &padding,
1587            4,
1588            nk,
1589            dom,
1590            ivk,
1591            selected_rho,
1592            selected_rseed,
1593            &imt_proof,
1594            &requested_nfs,
1595        );
1596    }
1597
1598    #[test]
1599    fn test_derive_synthetic_padding_note_matches_manual_derivation() {
1600        let mut rng = OsRng;
1601        let sk = SpendingKey::random(&mut rng);
1602        let fvk: FullViewingKey = (&sk).into();
1603        let ak: SpendValidatingKey = fvk.clone().into();
1604        let nk = fvk.nk().inner();
1605        let ivk = external_ivk_scalar(&fvk, &ak);
1606        let slot_index = 3;
1607        let (_padded_note, rho, rseed) = precomputed_padding_note(&mut rng);
1608
1609        let derived = derive_synthetic_padding_note(
1610            nk,
1611            ivk,
1612            slot_index,
1613            rho,
1614            rseed,
1615            PrecomputedRandomnessLocation::PaddedNote(0),
1616        )
1617        .unwrap();
1618
1619        let (expected_g_d, expected_pk_d) =
1620            padding_points(slot_index, ivk).expect("test padding points should be valid");
1621        let expected_psi = rseed.psi(&rho);
1622        let expected_rcm = rseed.rcm(&rho);
1623        let expected_cm = note_commitment_point(
1624            *expected_g_d,
1625            *expected_pk_d,
1626            NoteValue::ZERO,
1627            rho.into_inner(),
1628            expected_psi,
1629            expected_rcm.inner(),
1630        )
1631        .expect("test padding commitment should be valid")
1632        .to_affine();
1633        let expected_nf = derive_note_nullifier(nk, rho.into_inner(), expected_psi, expected_cm)
1634            .expect("test padding nullifier should be valid");
1635
1636        assert_eq!(derived.g_d_pad, expected_g_d);
1637        assert_eq!(derived.pk_d_pad, expected_pk_d);
1638        assert_eq!(derived.psi, expected_psi);
1639        assert_eq!(derived.rcm.inner(), expected_rcm.inner());
1640        assert_eq!(derived.cm, expected_cm);
1641        assert_eq!(derived.cmx, *expected_cm.coordinates().unwrap().x());
1642        assert_eq!(derived.real_nf, expected_nf);
1643    }
1644
1645    #[test]
1646    fn test_synthetic_padding_note_parts_matches_padding_slot_derivation() {
1647        let mut rng = OsRng;
1648        let sk = SpendingKey::random(&mut rng);
1649        let fvk: FullViewingKey = (&sk).into();
1650        let ak: SpendValidatingKey = fvk.clone().into();
1651        let nk = fvk.nk().inner();
1652        let dom = derive_nullifier_domain(pallas::Base::random(&mut rng));
1653        let ivk = external_ivk_scalar(&fvk, &ak);
1654        let imt = RecordingImtProvider::returning(test_imt_proof());
1655
1656        let (padded_note, rho, rseed) = precomputed_padding_note(&mut rng);
1657        let precomputed = PrecomputedRandomness {
1658            padded_notes: vec![padded_note],
1659            rseed_signed: [0; 32],
1660            rseed_output: [0; 32],
1661        };
1662
1663        let padding =
1664            build_padding_slot(3, 0, nk, dom, ivk, &imt, &mut rng, Some(&precomputed)).unwrap();
1665        let parts = crate::delegation::synthetic_padding_note_parts(&fvk, 3, rho, rseed).unwrap();
1666
1667        assert_eq!(
1668            parts,
1669            SyntheticPaddingNoteParts {
1670                cmx: padding.cmx.to_repr(),
1671                nullifier: padding.real_nf.to_repr(),
1672            }
1673        );
1674        assert_eq!(imt.requested_nfs.borrow().as_slice(), &[padding.real_nf]);
1675    }
1676
1677    #[test]
1678    fn test_synthetic_padding_note_parts_rejects_impossible_slot_indices() {
1679        let mut rng = OsRng;
1680        let sk = SpendingKey::random(&mut rng);
1681        let fvk: FullViewingKey = (&sk).into();
1682        let (_padded_note, rho, rseed) = precomputed_padding_note(&mut rng);
1683
1684        for slot_index in [0, circuit::MAX_REAL_NOTES, usize::MAX] {
1685            assert!(matches!(
1686                crate::delegation::synthetic_padding_note_parts(&fvk, slot_index, rho, rseed),
1687                Err(DelegationBuildError::InvalidPaddingSlotIndex { slot_index: actual })
1688                    if actual == slot_index
1689            ));
1690        }
1691    }
1692
1693    #[test]
1694    fn test_build_padding_slot_propagates_imt_errors() {
1695        let mut rng = OsRng;
1696        let sk = SpendingKey::random(&mut rng);
1697        let fvk: FullViewingKey = (&sk).into();
1698        let ak: SpendValidatingKey = fvk.clone().into();
1699        let imt = RecordingImtProvider::failing(ImtError("fixture failure".to_string()));
1700
1701        let result = build_padding_slot(
1702            2,
1703            0,
1704            fvk.nk().inner(),
1705            derive_nullifier_domain(pallas::Base::random(&mut rng)),
1706            external_ivk_scalar(&fvk, &ak),
1707            &imt,
1708            &mut rng,
1709            None,
1710        );
1711
1712        assert!(matches!(
1713            result,
1714            Err(DelegationBuildError::ImtFetchFailed(ImtError(message)))
1715                if message == "fixture failure"
1716        ));
1717        assert_eq!(imt.requested_nfs.borrow().len(), 1);
1718    }
1719
1720    #[test]
1721    fn test_build_padding_slot_rejects_missing_precomputed_padding_entry() {
1722        let mut rng = OsRng;
1723        let sk = SpendingKey::random(&mut rng);
1724        let fvk: FullViewingKey = (&sk).into();
1725        let ak: SpendValidatingKey = fvk.clone().into();
1726        let imt = RecordingImtProvider::returning(test_imt_proof());
1727        let precomputed = PrecomputedRandomness {
1728            padded_notes: vec![],
1729            rseed_signed: [0; 32],
1730            rseed_output: [0; 32],
1731        };
1732
1733        let result = build_padding_slot(
1734            1,
1735            0,
1736            fvk.nk().inner(),
1737            derive_nullifier_domain(pallas::Base::random(&mut rng)),
1738            external_ivk_scalar(&fvk, &ak),
1739            &imt,
1740            &mut rng,
1741            Some(&precomputed),
1742        );
1743
1744        assert!(matches!(
1745            result,
1746            Err(DelegationBuildError::MissingPrecomputedPaddedNote {
1747                index: 0,
1748                actual: 0
1749            })
1750        ));
1751    }
1752
1753    #[test]
1754    fn test_four_real_notes_builds_expected_output_shape() {
1755        // 3,200,000 x 4 = 12,800,000 → num_ballots = 1, remainder = 300,000.
1756        build_bundle(
1757            &[3_200_000, 3_200_000, 3_200_000, 3_200_000],
1758            &[
1759                Scope::External,
1760                Scope::External,
1761                Scope::External,
1762                Scope::External,
1763            ],
1764        );
1765    }
1766
1767    #[test]
1768    fn test_two_real_notes_builds_expected_output_shape() {
1769        build_bundle(&[7_000_000, 7_000_000], &[Scope::External, Scope::External]);
1770    }
1771
1772    #[test]
1773    fn test_min_weight_boundary_builds_expected_output_shape() {
1774        // v_total = 12,500,000 exactly → num_ballots = 1, remainder = 0. Should pass.
1775        build_bundle(&[12_500_000], &[Scope::External]);
1776    }
1777
1778    #[test]
1779    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1780    fn test_below_one_ballot() {
1781        // v_total = 12,499,999 → num_ballots = 0. Circuit should fail
1782        // (non-zero check on num_ballots causes nb_minus_one to wrap).
1783        let mut rng = OsRng;
1784        let sk = SpendingKey::random(&mut rng);
1785        let fvk: FullViewingKey = (&sk).into();
1786        let output_recipient = fvk.address_at(1u32, Scope::External);
1787        let vote_round_id = pallas::Base::random(&mut rng);
1788        let van_comm_rand = pallas::Base::random(&mut rng);
1789        let alpha = pallas::Scalar::random(&mut rng);
1790
1791        let imt = SpacedLeafImtProvider::new();
1792        let (inputs, nc_root) =
1793            make_real_note_inputs(&fvk, &[12_499_999], &[Scope::External], &imt, &mut rng);
1794
1795        let bundle = build_delegation_bundle(
1796            inputs,
1797            &fvk,
1798            alpha,
1799            output_recipient,
1800            vote_round_id,
1801            nc_root,
1802            van_comm_rand,
1803            &imt,
1804            &mut rng,
1805            None,
1806        )
1807        .unwrap();
1808
1809        let pi = bundle.instance.to_halo2_instance();
1810        let prover = MockProver::run(K, &bundle.circuit, vec![pi]).unwrap();
1811        assert!(prover.verify().is_err(), "below one ballot should fail");
1812    }
1813
1814    #[test]
1815    fn test_three_ballots_builds_expected_output_shape() {
1816        // 3 notes × 12,500,000 = 37,500,000 → num_ballots = 3, remainder = 0.
1817        build_bundle(
1818            &[12_500_000, 12_500_000, 12_500_000],
1819            &[Scope::External, Scope::External, Scope::External],
1820        );
1821    }
1822
1823    #[test]
1824    fn test_zero_notes_error() {
1825        let mut rng = OsRng;
1826        let sk = SpendingKey::random(&mut rng);
1827        let fvk: FullViewingKey = (&sk).into();
1828        let output_recipient = fvk.address_at(1u32, Scope::External);
1829        let imt = SpacedLeafImtProvider::new();
1830
1831        let result = build_delegation_bundle(
1832            vec![],
1833            &fvk,
1834            pallas::Scalar::random(&mut rng),
1835            output_recipient,
1836            pallas::Base::random(&mut rng),
1837            pallas::Base::random(&mut rng),
1838            pallas::Base::random(&mut rng),
1839            &imt,
1840            &mut rng,
1841            None,
1842        );
1843
1844        assert!(matches!(
1845            result,
1846            Err(DelegationBuildError::InvalidNoteCount(0))
1847        ));
1848    }
1849
1850    #[test]
1851    fn test_five_real_notes_builds_expected_output_shape() {
1852        // 2,500,000 x 5 = 12,500,000 → num_ballots = 1, remainder = 0.
1853        build_bundle(
1854            &[2_500_000, 2_500_000, 2_500_000, 2_500_000, 2_500_000],
1855            &[
1856                Scope::External,
1857                Scope::External,
1858                Scope::External,
1859                Scope::External,
1860                Scope::External,
1861            ],
1862        );
1863    }
1864
1865    #[test]
1866    fn test_six_notes_error() {
1867        let mut rng = OsRng;
1868        let sk = SpendingKey::random(&mut rng);
1869        let fvk: FullViewingKey = (&sk).into();
1870        let output_recipient = fvk.address_at(1u32, Scope::External);
1871        let imt = SpacedLeafImtProvider::new();
1872
1873        let (inputs, _) = make_real_note_inputs(
1874            &fvk,
1875            &[3_000_000, 3_000_000, 3_000_000, 3_000_000, 3_000_000],
1876            &[
1877                Scope::External,
1878                Scope::External,
1879                Scope::External,
1880                Scope::External,
1881                Scope::External,
1882            ],
1883            &imt,
1884            &mut rng,
1885        );
1886        // Add a 6th note by extending.
1887        let mut inputs = inputs;
1888        let (extra, _) =
1889            make_real_note_inputs(&fvk, &[3_000_000], &[Scope::External], &imt, &mut rng);
1890        inputs.extend(extra);
1891
1892        let result = build_delegation_bundle(
1893            inputs,
1894            &fvk,
1895            pallas::Scalar::random(&mut rng),
1896            output_recipient,
1897            pallas::Base::random(&mut rng),
1898            pallas::Base::random(&mut rng),
1899            pallas::Base::random(&mut rng),
1900            &imt,
1901            &mut rng,
1902            None,
1903        );
1904
1905        assert!(matches!(
1906            result,
1907            Err(DelegationBuildError::InvalidNoteCount(6))
1908        ));
1909    }
1910
1911    #[test]
1912    fn test_missing_precomputed_padded_note_returns_error() {
1913        let precomputed = PrecomputedRandomness {
1914            padded_notes: vec![],
1915            rseed_signed: [0u8; 32],
1916            rseed_output: [0u8; 32],
1917        };
1918
1919        let result = build_single_note_bundle_with_precomputed(&precomputed);
1920
1921        assert!(matches!(
1922            result,
1923            Err(DelegationBuildError::MissingPrecomputedPaddedNote {
1924                index: 0,
1925                actual: 0
1926            })
1927        ));
1928    }
1929
1930    #[test]
1931    fn test_partial_precomputed_padded_notes_returns_later_missing_error() {
1932        let mut rng = OsRng;
1933        let sk = SpendingKey::random(&mut rng);
1934        let fvk: FullViewingKey = (&sk).into();
1935        let precomputed = PrecomputedRandomness {
1936            padded_notes: vec![
1937                make_valid_padded_note_data(&mut rng),
1938                make_valid_padded_note_data(&mut rng),
1939            ],
1940            rseed_signed: [0u8; 32],
1941            rseed_output: [0u8; 32],
1942        };
1943
1944        let result =
1945            build_single_note_bundle_with_fvk_and_precomputed(&fvk, &precomputed, &mut rng);
1946
1947        assert!(matches!(
1948            result,
1949            Err(DelegationBuildError::MissingPrecomputedPaddedNote {
1950                index: 2,
1951                actual: 2
1952            })
1953        ));
1954    }
1955
1956    #[test]
1957    fn test_invalid_precomputed_padded_rho_returns_error() {
1958        let precomputed = PrecomputedRandomness {
1959            padded_notes: vec![PaddedNoteData {
1960                rho: [0xffu8; 32],
1961                rseed: [0u8; 32],
1962            }],
1963            rseed_signed: [0u8; 32],
1964            rseed_output: [0u8; 32],
1965        };
1966
1967        let result = build_single_note_bundle_with_precomputed(&precomputed);
1968
1969        assert!(matches!(
1970            result,
1971            Err(DelegationBuildError::InvalidPrecomputedRho { index: 0 })
1972        ));
1973    }
1974
1975    #[test]
1976    fn test_single_internal_note_builds_expected_output_shape() {
1977        build_bundle(&[13_000_000], &[Scope::Internal]);
1978    }
1979
1980    #[test]
1981    #[ignore = "long-running Halo2 circuit test; run with `cargo test -- --ignored`"]
1982    fn test_mixed_scope_notes() {
1983        let bundle = build_bundle(
1984            &[4_000_000, 4_000_000, 3_000_000, 2_000_000],
1985            &[
1986                Scope::External,
1987                Scope::Internal,
1988                Scope::External,
1989                Scope::Internal,
1990            ],
1991        );
1992        verify_bundle(&bundle);
1993    }
1994
1995    #[test]
1996    fn test_all_internal_notes_builds_expected_output_shape() {
1997        build_bundle(
1998            &[4_000_000, 4_000_000, 3_000_000, 2_000_000],
1999            &[
2000                Scope::Internal,
2001                Scope::Internal,
2002                Scope::Internal,
2003                Scope::Internal,
2004            ],
2005        );
2006    }
2007}