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