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