Skip to main content

voting_circuits/delegation/
circuit.rs

1//! The Delegation circuit implementation.
2//!
3//! A single circuit proving all 14 conditions of the delegation ZKP:
4//!
5//! - **Condition 1**: Signed note commitment integrity.
6//! - **Condition 2**: Nullifier integrity.
7//! - **Condition 3**: Rho binding — keystone rho = Poseidon(cmx_1..5, van_comm, vote_round_id).
8//! - **Condition 4**: Spend authority.
9//! - **Condition 5**: CommitIvk & diversified address integrity.
10//! - **Condition 6**: Output note commitment integrity.
11//! - **Condition 7**: Governance commitment integrity (hashes `num_ballots`).
12//! - **Condition 8**: Ballot scaling (`num_ballots = floor(v_total / 12,500,000)`).
13//! - **Condition 9** (×5): Note commitment integrity.
14//! - **Condition 10** (×5): Merkle path validity (gated by value; dummy notes skip).
15//! - **Condition 11** (×5): Diversified address integrity.
16//! - **Condition 12** (×5): Private nullifier derivation.
17//! - **Condition 13** (×5): IMT non-membership.
18//! - **Condition 14** (×5): Alternate nullifier integrity.
19
20use group::{Curve, GroupEncoding};
21use halo2_proofs::{
22    circuit::{floor_planner, AssignedCell, Layouter, Value},
23    plonk::{self, Advice, Column, Constraints, Instance as InstanceColumn, Selector},
24    poly::Rotation,
25};
26use pasta_curves::{arithmetic::CurveAffine, pallas, vesta};
27use std::vec::Vec;
28
29use super::imt::IMT_DEPTH;
30use super::imt_circuit::{synthesize_imt_non_membership, ImtNonMembershipConfig};
31use crate::circuit::address_ownership::prove_address_ownership;
32use crate::circuit::gadget::assign_constant;
33use crate::circuit::mul_chip::{MulChip, MulConfig, MulInstruction};
34use crate::circuit::van_integrity;
35use halo2_gadgets::{
36    ecc::{
37        chip::{EccChip, EccConfig},
38        NonIdentityPoint, Point, ScalarFixed, ScalarVar,
39    },
40    poseidon::{
41        primitives::{self as poseidon, ConstantLength},
42        Hash as PoseidonHash, Pow5Chip as PoseidonChip, Pow5Config as PoseidonConfig,
43    },
44    sinsemilla::{
45        chip::{SinsemillaChip, SinsemillaConfig},
46        merkle::{
47            chip::{MerkleChip, MerkleConfig},
48            MerklePath as GadgetMerklePath,
49        },
50    },
51    utilities::{
52        bool_check,
53        lookup_range_check::{LookupRangeCheck, LookupRangeCheckConfig},
54    },
55};
56use orchard::constants::MERKLE_DEPTH_ORCHARD;
57use orchard::{
58    circuit::{
59        commit_ivk::{CommitIvkChip, CommitIvkConfig},
60        gadget::{
61            add_chip::{AddChip, AddConfig},
62            assign_free_advice, derive_nullifier, note_commit, AddInstruction,
63        },
64        note_commit::{NoteCommitChip, NoteCommitConfig},
65    },
66    constants::{OrchardCommitDomains, OrchardFixedBases, OrchardHashDomains},
67    keys::{
68        CommitIvkRandomness, DiversifiedTransmissionKey, FullViewingKey, NullifierDerivingKey,
69        Scope, SpendValidatingKey,
70    },
71    note::{
72        commitment::{NoteCommitTrapdoor, NoteCommitment},
73        nullifier::Nullifier,
74        Note,
75    },
76    primitives::redpallas::{SpendAuth, VerificationKey},
77    spec::NonIdentityPallasPoint,
78    tree::MerkleHashOrchard,
79    value::NoteValue,
80};
81
82// ================================================================
83// Circuit size
84// ================================================================
85
86/// Circuit size (2^K rows).
87///
88/// K=14 (16,384 rows) fits all 15 conditions including 5 per-note slots
89/// with Sinsemilla NoteCommit, Merkle paths, IMT non-membership, and
90/// ECC operations.
91pub const K: u32 = 14;
92
93// ================================================================
94// Public input offsets (14 field elements).
95// ================================================================
96
97/// Public input offset for the derived nullifier.
98const NF_SIGNED: usize = 0;
99/// Public input offset for rk (x-coordinate).
100const RK_X: usize = 1;
101/// Public input offset for rk (y-coordinate).
102const RK_Y: usize = 2;
103/// Public input offset for the output note's extracted commitment (condition 6).
104const CMX_NEW: usize = 3;
105/// Public input offset for the governance commitment.
106const VAN_COMM: usize = 4;
107/// Public input offset for the vote round identifier.
108const VOTE_ROUND_ID: usize = 5;
109/// Public input offset for the note commitment tree root.
110const NC_ROOT: usize = 6;
111/// Public input offset for the nullifier IMT root.
112const NF_IMT_ROOT: usize = 7;
113/// Public input offsets for per-note governance nullifiers (derived from real notes).
114const GOV_NULL_1: usize = 8;
115const GOV_NULL_2: usize = 9;
116const GOV_NULL_3: usize = 10;
117const GOV_NULL_4: usize = 11;
118const GOV_NULL_5: usize = 12;
119
120/// Gov null offsets indexed by note slot.
121const GOV_NULL_OFFSETS: [usize; 5] = [GOV_NULL_1, GOV_NULL_2, GOV_NULL_3, GOV_NULL_4, GOV_NULL_5];
122/// Public input offset for the nullifier domain.
123const DOM: usize = 13;
124
125/// Maximum proposal authority — the default for a fresh delegation.
126///
127/// Represented as a 16-bit bitmask where each bit authorizes voting on the
128/// corresponding proposal (proposal ID = bit index from LSB).  Full authority
129/// is `2^16 - 1 = 65535`. Only bits 1–15 correspond to usable proposals
130/// (proposal IDs are 1-indexed); bit 0 is the circuit's sentinel value,
131/// permanently set and never decremented.
132///
133/// This constant is hashed into `van_comm` (condition 7) as a constant-
134/// constrained witness, baked into the verification key so a malicious prover
135/// cannot substitute a different authority value.
136pub(crate) const MAX_PROPOSAL_AUTHORITY: u64 = 65535; // 2^16 - 1
137
138/// Out-of-circuit rho binding hash used by the builder and tests.
139pub(crate) fn rho_binding_hash(
140    cmx_1: pallas::Base,
141    cmx_2: pallas::Base,
142    cmx_3: pallas::Base,
143    cmx_4: pallas::Base,
144    cmx_5: pallas::Base,
145    van_comm: pallas::Base,
146    vote_round_id: pallas::Base,
147) -> pallas::Base {
148    poseidon::Hash::<_, poseidon::P128Pow5T3, ConstantLength<7>, 3, 2>::init().hash([
149        cmx_1,
150        cmx_2,
151        cmx_3,
152        cmx_4,
153        cmx_5,
154        van_comm,
155        vote_round_id,
156    ])
157}
158
159/// Ballot divisor for converting raw zatoshi balance to ballot count.
160///
161/// `num_ballots = floor(v_total / BALLOT_DIVISOR)`
162pub(crate) const BALLOT_DIVISOR: u64 = 12_500_000;
163
164/// Out-of-circuit governance commitment hash used by the builder and tests.
165///
166/// Delegates to `van_integrity::van_integrity_hash` with
167/// `MAX_PROPOSAL_AUTHORITY` as the proposal authority (fresh delegation).
168/// The `value` parameter is `num_ballots` (ballot count after floor-division),
169/// NOT the raw zatoshi sum.
170pub(crate) fn van_commitment_hash(
171    g_d_new_x: pallas::Base,
172    pk_d_new_x: pallas::Base,
173    num_ballots: pallas::Base,
174    vote_round_id: pallas::Base,
175    van_comm_rand: pallas::Base,
176) -> pallas::Base {
177    van_integrity::van_integrity_hash(
178        g_d_new_x,
179        pk_d_new_x,
180        num_ballots,
181        vote_round_id,
182        pallas::Base::from(MAX_PROPOSAL_AUTHORITY),
183        van_comm_rand,
184    )
185}
186
187// ================================================================
188// Config
189// ================================================================
190
191/// Configuration for the Delegation circuit.
192#[derive(Clone, Debug)]
193pub struct Config {
194    // The instance column (public inputs)
195    primary: Column<InstanceColumn>,
196    // 10 advice columns for private witness data.
197    // This is the scratch space where the prover places intermediate values during computation.
198    // Various chips use these columns
199    // Poseidon: [5..9]
200    // ECC: uses all 10
201    // AddChip: uses [6..9]
202    advices: [Column<Advice>; 10],
203    // Configuration for the AddChip which constrains a + b = c over field elements.
204    // Used inside DeriveNullifier to combine intermediate values.
205    add_config: AddConfig,
206    // Configuration for the MulChip which constrains a * b = c over field elements.
207    // Used in condition 8 (ballot scaling) to compute num_ballots * BALLOT_DIVISOR.
208    mul_config: MulConfig,
209    // Configuration for the ECCChip which provides elliptic curve operations
210    // (point addition, scalar multiplication) on the Pallas curve with Orchard's fixes bases.
211    // We use it to convert cm_signed from NoteCommitment to a Field point for the DeriveNullifier function.
212    ecc_config: EccConfig<OrchardFixedBases>,
213    // Poseidon chip config. Used in the DeriveNullifier.
214    poseidon_config: PoseidonConfig<pallas::Base, 3, 2>,
215    // Sinsemilla config 1 — used for loading the lookup table that
216    // LookupRangeCheckConfig (and thus EccChip) depends on, for CommitIvk,
217    // and for the signed note's NoteCommit. Uses advices[..5].
218    sinsemilla_config_1:
219        SinsemillaConfig<OrchardHashDomains, OrchardCommitDomains, OrchardFixedBases>,
220    // Sinsemilla config 2 — a second instance for the output note's NoteCommit.
221    // Uses advices[5..] so the two Sinsemilla chips can lay out side-by-side.
222    // Two are needed for each NoteCommit. If these were reused, gates would conflict.
223    sinsemilla_config_2:
224        SinsemillaConfig<OrchardHashDomains, OrchardCommitDomains, OrchardFixedBases>,
225    // Configuration to handle decomposition and canonicity checking for CommitIvk.
226    commit_ivk_config: CommitIvkConfig,
227    // Configuration for decomposition and canonicity checking for the signed note's NoteCommit.
228    signed_note_commit_config: NoteCommitConfig,
229    // Configuration for decomposition and canonicity checking for the output note's NoteCommit.
230    new_note_commit_config: NoteCommitConfig,
231    // Range check configuration for the 10-bit lookup table.
232    // Used in condition 8 (ballot scaling) to range-check nb_minus_one (30 bits
233    // direct) and remainder (24 bits via shift-by-2^6 into 30-bit check).
234    range_check: LookupRangeCheckConfig<pallas::Base, 10>,
235    // Merkle config 1 — Sinsemilla-based Merkle path verification for condition 10.
236    // Paired with sinsemilla_config_1. Uses advices[..5].
237    merkle_config_1: MerkleConfig<OrchardHashDomains, OrchardCommitDomains, OrchardFixedBases>,
238    // Merkle config 2 — second Merkle chip for condition 10, paired with sinsemilla_config_2.
239    // Uses advices[5..]. Two configs are required because MerkleChip alternates between
240    // them at each tree level (even levels use config 1, odd levels use config 2).
241    merkle_config_2: MerkleConfig<OrchardHashDomains, OrchardCommitDomains, OrchardFixedBases>,
242    // Per-note custom gate selector (conditions 10, 13).
243    // Enforces: v * (root - nc_root) = 0 (Merkle check, skipped for v=0 dummy notes),
244    // imt_root = nf_imt_root.
245    q_per_note: Selector,
246    // Per-note scope selection gate (condition 11).
247    // Muxes between ivk (external) and ivk_internal based on is_internal flag.
248    q_scope_select: Selector,
249    // IMT non-membership gates (condition 13): conditional swap + interval check.
250    imt_config: ImtNonMembershipConfig,
251}
252
253impl Config {
254    fn add_chip(&self) -> AddChip {
255        AddChip::construct(self.add_config.clone())
256    }
257
258    fn mul_chip(&self) -> MulChip {
259        MulChip::construct(self.mul_config.clone())
260    }
261
262    fn ecc_chip(&self) -> EccChip<OrchardFixedBases> {
263        EccChip::construct(self.ecc_config.clone())
264    }
265
266    // Operating over the Pallas base field, with a width of 3 (state size) and rate of 2
267    // 3 comes from the P128Pow5T3 construction used throughout Orchard (i.e. 3 is width)
268    // Rate of 2 means that two elements are absorbed per permutation, so the hash completes
269    // in fewer rounds than rate 1, roughly halving the number of Poseidon permutations.
270    fn poseidon_chip(&self) -> PoseidonChip<pallas::Base, 3, 2> {
271        PoseidonChip::construct(self.poseidon_config.clone())
272    }
273
274    fn commit_ivk_chip(&self) -> CommitIvkChip {
275        CommitIvkChip::construct(self.commit_ivk_config.clone())
276    }
277
278    fn sinsemilla_chip_1(
279        &self,
280    ) -> SinsemillaChip<OrchardHashDomains, OrchardCommitDomains, OrchardFixedBases> {
281        SinsemillaChip::construct(self.sinsemilla_config_1.clone())
282    }
283
284    fn sinsemilla_chip_2(
285        &self,
286    ) -> SinsemillaChip<OrchardHashDomains, OrchardCommitDomains, OrchardFixedBases> {
287        SinsemillaChip::construct(self.sinsemilla_config_2.clone())
288    }
289
290    fn note_commit_chip_signed(&self) -> NoteCommitChip {
291        NoteCommitChip::construct(self.signed_note_commit_config.clone())
292    }
293
294    fn note_commit_chip_new(&self) -> NoteCommitChip {
295        NoteCommitChip::construct(self.new_note_commit_config.clone())
296    }
297
298    fn merkle_chip_1(
299        &self,
300    ) -> MerkleChip<OrchardHashDomains, OrchardCommitDomains, OrchardFixedBases> {
301        MerkleChip::construct(self.merkle_config_1.clone())
302    }
303
304    fn merkle_chip_2(
305        &self,
306    ) -> MerkleChip<OrchardHashDomains, OrchardCommitDomains, OrchardFixedBases> {
307        MerkleChip::construct(self.merkle_config_2.clone())
308    }
309
310    fn range_check_config(&self) -> LookupRangeCheckConfig<pallas::Base, 10> {
311        self.range_check
312    }
313}
314
315// ================================================================
316// NoteSlotWitness
317// ================================================================
318
319/// Private witness data for a single note slot (conditions 9–14).
320#[derive(Clone, Debug, Default)]
321pub struct NoteSlotWitness {
322    pub(crate) g_d: Value<NonIdentityPallasPoint>,
323    pub(crate) pk_d: Value<NonIdentityPallasPoint>,
324    pub(crate) v: Value<NoteValue>,
325    pub(crate) rho: Value<pallas::Base>,
326    pub(crate) psi: Value<pallas::Base>,
327    pub(crate) rcm: Value<NoteCommitTrapdoor>,
328    pub(crate) cm: Value<NoteCommitment>,
329    pub(crate) path: Value<[MerkleHashOrchard; MERKLE_DEPTH_ORCHARD]>,
330    pub(crate) pos: Value<u32>,
331    pub(crate) imt_nf_bounds: Value<[pallas::Base; 3]>,
332    pub(crate) imt_leaf_pos: Value<u32>,
333    pub(crate) imt_path: Value<[pallas::Base; IMT_DEPTH]>,
334    /// Whether this note uses the internal (change) scope.
335    /// When true, `ivk_internal` is used for Condition 11 instead of `ivk`.
336    pub(crate) is_internal: Value<bool>,
337}
338
339// ================================================================
340// Circuit
341// ================================================================
342
343/// The Delegation circuit.
344///
345/// Proves all 15 conditions of the delegation ZKP (see README for details).
346#[derive(Clone, Debug, Default)]
347pub struct Circuit {
348    // Signed note witnesses (conditions 1–5).
349    nk: Value<NullifierDerivingKey>,
350    rho_signed: Value<pallas::Base>,
351    psi_signed: Value<pallas::Base>,
352    cm_signed: Value<NoteCommitment>,
353    ak: Value<SpendValidatingKey>,
354    alpha: Value<pallas::Scalar>,
355    rivk: Value<CommitIvkRandomness>,
356    rivk_internal: Value<CommitIvkRandomness>,
357    rcm_signed: Value<NoteCommitTrapdoor>,
358    g_d_signed: Value<NonIdentityPallasPoint>,
359    pk_d_signed: Value<DiversifiedTransmissionKey>,
360    // Output note witnesses (condition 6).
361    // These are free witnesses.
362    g_d_new: Value<NonIdentityPallasPoint>,
363    pk_d_new: Value<DiversifiedTransmissionKey>,
364    psi_new: Value<pallas::Base>,
365    rcm_new: Value<NoteCommitTrapdoor>,
366    // Per-note slots (conditions 9–14).
367    notes: [NoteSlotWitness; 5],
368    // Gov commitment blinding factor (condition 7).
369    van_comm_rand: Value<pallas::Base>,
370    // Condition 8 (ballot scaling) witnesses.
371    // num_ballots = floor(v_total / BALLOT_DIVISOR), remainder = v_total % BALLOT_DIVISOR.
372    num_ballots: Value<pallas::Base>,
373    remainder: Value<pallas::Base>,
374}
375
376impl Circuit {
377    /// Constructs a `Circuit` from a note, its full viewing key, and the spend auth randomizer.
378    pub fn from_note_unchecked(fvk: &FullViewingKey, note: &Note, alpha: pallas::Scalar) -> Self {
379        let sender_address = note.recipient();
380        let rho_signed = note.rho();
381        let psi_signed = note.rseed().psi(&rho_signed);
382        let rcm_signed = note.rseed().rcm(&rho_signed);
383        Circuit {
384            nk: Value::known(*fvk.nk()),
385            rho_signed: Value::known(rho_signed.into_inner()),
386            psi_signed: Value::known(psi_signed),
387            cm_signed: Value::known(note.commitment()),
388            ak: Value::known(fvk.clone().into()),
389            alpha: Value::known(alpha),
390            rivk: Value::known(fvk.rivk(Scope::External)),
391            rivk_internal: Value::known(fvk.rivk(Scope::Internal)),
392            rcm_signed: Value::known(rcm_signed),
393            g_d_signed: Value::known(sender_address.g_d()),
394            pk_d_signed: Value::known(*sender_address.pk_d()),
395            ..Default::default()
396        }
397    }
398
399    /// Sets the output note witness fields (condition 6).
400    pub fn with_output_note(mut self, output_note: &Note) -> Self {
401        let rho_new = output_note.rho();
402        let psi_new = output_note.rseed().psi(&rho_new);
403        let rcm_new = output_note.rseed().rcm(&rho_new);
404        self.g_d_new = Value::known(output_note.recipient().g_d());
405        self.pk_d_new = Value::known(*output_note.recipient().pk_d());
406        self.psi_new = Value::known(psi_new);
407        self.rcm_new = Value::known(rcm_new);
408        self
409    }
410
411    /// Sets the five per-note slot witnesses (conditions 9–14).
412    pub fn with_notes(mut self, notes: [NoteSlotWitness; 5]) -> Self {
413        self.notes = notes;
414        self
415    }
416
417    /// Sets the governance commitment blinding factor (condition 7).
418    pub fn with_van_comm_rand(mut self, van_comm_rand: pallas::Base) -> Self {
419        self.van_comm_rand = Value::known(van_comm_rand);
420        self
421    }
422
423    /// Sets the ballot scaling witnesses (condition 8).
424    pub fn with_ballot_scaling(
425        mut self,
426        num_ballots: pallas::Base,
427        remainder: pallas::Base,
428    ) -> Self {
429        self.num_ballots = Value::known(num_ballots);
430        self.remainder = Value::known(remainder);
431        self
432    }
433}
434
435// ================================================================
436// plonk::Circuit implementation
437// ================================================================
438
439impl plonk::Circuit<pallas::Base> for Circuit {
440    type Config = Config;
441    type FloorPlanner = floor_planner::V1;
442
443    fn without_witnesses(&self) -> Self {
444        Self::default()
445    }
446
447    fn configure(meta: &mut plonk::ConstraintSystem<pallas::Base>) -> Self::Config {
448        // ── Column declarations ──────────────────────────────────────────
449
450        // 10 advice columns — the minimum budget that satisfies every sub-chip
451        // simultaneously when their column ranges are overlapped:
452        //
453        //   EccChip               advices[0..10]  (needs all 10)
454        //   Sinsemilla/Merkle #1  advices[0..5] + advices[6] as witness
455        //   Sinsemilla/Merkle #2  advices[5..10] + advices[7] as witness
456        //   PoseidonChip          advices[5..9]   (partial-sbox + state)
457        //   AddChip / MulChip     advices[6..9]
458        //   LookupRangeCheck      advices[9]
459        //
460        // The two Sinsemilla pairs intentionally share advices[5..7]; each pair's
461        // gates are gated by their own selectors and are never active on the same
462        // rows, so the overlap is safe. Without it we would need 12 columns. EccChip
463        // is the widest consumer and already requires 10, so everything else fits
464        // within that budget. This matches the upstream Orchard column count.
465        let advices = [
466            meta.advice_column(),
467            meta.advice_column(),
468            meta.advice_column(),
469            meta.advice_column(),
470            meta.advice_column(),
471            meta.advice_column(),
472            meta.advice_column(),
473            meta.advice_column(),
474            meta.advice_column(),
475            meta.advice_column(),
476        ];
477
478        // Instance column used for public inputs.
479        let primary = meta.instance_column();
480
481        // Fixed columns for the Sinsemilla generator lookup table.
482        let table_idx = meta.lookup_table_column();
483        let lookup = (
484            table_idx,
485            meta.lookup_table_column(),
486            meta.lookup_table_column(),
487        );
488
489        // 8 fixed columns shared between ECC (Lagrange interpolation coefficients)
490        // and Poseidon (round constants). Different rows hold different data.
491        let lagrange_coeffs = [
492            meta.fixed_column(),
493            meta.fixed_column(),
494            meta.fixed_column(),
495            meta.fixed_column(),
496            meta.fixed_column(),
497            meta.fixed_column(),
498            meta.fixed_column(),
499            meta.fixed_column(),
500        ];
501        let rc_a = lagrange_coeffs[2..5].try_into().unwrap();
502        let rc_b = lagrange_coeffs[5..8].try_into().unwrap();
503
504        // ── Column properties ────────────────────────────────────────────
505
506        // Enable equality constraints (permutation argument) on all advice columns
507        // and the instance column, so any cell can be copy-constrained to any other.
508        meta.enable_equality(primary);
509        for advice in advices.iter() {
510            meta.enable_equality(*advice);
511        }
512
513        // Use the first Lagrange coefficient column for loading global constants.
514        meta.enable_constant(lagrange_coeffs[0]);
515
516        // ── Custom gates ─────────────────────────────────────────────────
517
518        // Per-note custom gates (conditions 10, 13).
519        // q_per_note is a selector that activates these constraints only on rows
520        // where note data is assigned. Each of the (up to 5) input notes gets one
521        // such row; on all other rows the selector is 0 and the gate is inactive.
522        let q_per_note = meta.selector();
523        meta.create_gate("Per-note checks", |meta| {
524            let q_per_note = meta.query_selector(q_per_note);
525            let v = meta.query_advice(advices[0], Rotation::cur());
526            let root = meta.query_advice(advices[1], Rotation::cur());
527            let anchor = meta.query_advice(advices[2], Rotation::cur());
528            let imt_root = meta.query_advice(advices[3], Rotation::cur());
529            let nf_imt_root = meta.query_advice(advices[4], Rotation::cur());
530
531            Constraints::with_selector(
532                q_per_note,
533                [
534                    // Cond 10: Merkle root must match the public nc_root for notes
535                    // with non-zero value. Dummy notes (v=0) skip this check, matching
536                    // Orchard's standard dummy note mechanism (ZIP §Note Padding).
537                    ("v * (root - anchor) = 0", v * (root - anchor)),
538                    // Cond 13: IMT root from non-membership proof must match public
539                    // nf_imt_root. Not gated — dummy notes check too.
540                    ("imt_root = nf_imt_root", imt_root - nf_imt_root),
541                ],
542            )
543        });
544
545        // Scope selection gate (condition 11): muxes between external and internal ivk.
546        // Per-note, selects ivk or ivk_internal based on the is_internal flag, so that
547        // internal (change) notes use ivk_internal for the pk_d ownership check.
548        let q_scope_select = meta.selector();
549        meta.create_gate("scope ivk select", |meta| {
550            let q = meta.query_selector(q_scope_select);
551            let is_internal = meta.query_advice(advices[0], Rotation::cur());
552            let ivk = meta.query_advice(advices[1], Rotation::cur());
553            let ivk_internal = meta.query_advice(advices[2], Rotation::cur());
554            let selected_ivk = meta.query_advice(advices[3], Rotation::cur());
555            // selected_ivk = ivk + is_internal * (ivk_internal - ivk)
556            let expected = ivk.clone() + is_internal.clone() * (ivk_internal - ivk);
557            Constraints::with_selector(
558                q,
559                [
560                    ("bool_check is_internal", bool_check(is_internal)),
561                    ("scope select", selected_ivk - expected),
562                ],
563            )
564        });
565
566        // IMT non-membership gates (condition 13): conditional swap + interval check.
567        let imt_config = ImtNonMembershipConfig::configure(meta, &advices);
568
569        // ── Chip configurations ──────────────────────────────────────────
570
571        let add_config = AddChip::configure(meta, advices[7], advices[8], advices[6]);
572        let mul_config = MulChip::configure(meta, advices[7], advices[8], advices[6]);
573
574        // Range check configuration using the right-most advice column.
575        let range_check = LookupRangeCheckConfig::configure(meta, advices[9], table_idx);
576
577        let ecc_config =
578            EccChip::<OrchardFixedBases>::configure(meta, advices, lagrange_coeffs, range_check);
579
580        let poseidon_config = PoseidonChip::configure::<poseidon::P128Pow5T3>(
581            meta,
582            advices[6..9].try_into().unwrap(),
583            advices[5],
584            rc_a,
585            rc_b,
586        );
587
588        // Two Sinsemilla + Merkle chip pairs. NoteCommit internally needs two
589        // Sinsemilla instances (one per hash), so we can't reuse a single config.
590        // The Merkle chips alternate between the two at each tree level
591        // (even levels use pair 1, odd levels use pair 2) for the same reason.
592        //
593        // Column layout:
594        //   Pair 1: main = advices[0..5], witness = advices[6]
595        //   Pair 2: main = advices[5..10], witness = advices[7]
596        //
597        // The pairs intentionally overlap on advices[5..7] to keep the total
598        // column count at 10 (matching upstream Orchard). This is safe because
599        // each pair's gates are gated by their own selectors, and the two chips
600        // are never assigned to the same rows.
601        let configure_sinsemilla_merkle =
602            |meta: &mut plonk::ConstraintSystem<pallas::Base>,
603             advice_cols: [Column<Advice>; 5],
604             witness_col: Column<Advice>,
605             lagrange_col: Column<plonk::Fixed>| {
606                let sinsemilla = SinsemillaChip::configure(
607                    meta,
608                    advice_cols,
609                    witness_col,
610                    lagrange_col,
611                    lookup,
612                    range_check,
613                    false,
614                );
615                let merkle = MerkleChip::configure(meta, sinsemilla.clone());
616                (sinsemilla, merkle)
617            };
618
619        let (sinsemilla_config_1, merkle_config_1) = configure_sinsemilla_merkle(
620            meta,
621            advices[..5].try_into().unwrap(),
622            advices[6],
623            lagrange_coeffs[0],
624        );
625        let (sinsemilla_config_2, merkle_config_2) = configure_sinsemilla_merkle(
626            meta,
627            advices[5..].try_into().unwrap(),
628            advices[7],
629            lagrange_coeffs[1],
630        );
631
632        // Configuration to handle decomposition and canonicity checking for CommitIvk.
633        let commit_ivk_config = CommitIvkChip::configure(meta, advices);
634
635        // Configuration for decomposition and canonicity checking for the signed note's NoteCommit.
636        let signed_note_commit_config =
637            NoteCommitChip::configure(meta, advices, sinsemilla_config_1.clone());
638
639        // Configuration for decomposition and canonicity checking for the output note's NoteCommit.
640        let new_note_commit_config =
641            NoteCommitChip::configure(meta, advices, sinsemilla_config_2.clone());
642
643        Config {
644            primary,
645            advices,
646            add_config,
647            mul_config,
648            ecc_config,
649            poseidon_config,
650            sinsemilla_config_1,
651            sinsemilla_config_2,
652            commit_ivk_config,
653            signed_note_commit_config,
654            new_note_commit_config,
655            range_check,
656            merkle_config_1,
657            merkle_config_2,
658            q_per_note,
659            q_scope_select,
660            imt_config,
661        }
662    }
663
664    #[allow(non_snake_case)]
665    fn synthesize(
666        &self,
667        config: Self::Config,
668        mut layouter: impl Layouter<pallas::Base>,
669    ) -> Result<(), plonk::Error> {
670        // Load the Sinsemilla generator lookup table (needed by ECC range checks).
671        SinsemillaChip::load(config.sinsemilla_config_1.clone(), &mut layouter)?;
672
673        // Construct the ECC chip.
674        // It is needed to derive cm_signed and ak_P ECC points.
675        let ecc_chip = config.ecc_chip();
676
677        // Witness ak_P (spend validating key) as a non-identity curve point.
678        // Shared between spend authority and CommitIvk.
679        // If ak_P were allowed to be the identity point (zero of the curve group), it would be a degenerate
680        // key with no cryptographic strength - any signature would trivially verify against it.
681        // By constraining, we ensure that the delegated spend authority is backed by a real meaningful
682        // public key.
683        let ak_P: Value<pallas::Point> = self.ak.as_ref().map(|ak| ak.into());
684        let ak_P = NonIdentityPoint::new(
685            ecc_chip.clone(),
686            layouter.namespace(|| "witness ak_P"),
687            ak_P.map(|ak_P| ak_P.to_affine()),
688        )?;
689
690        // Witness g_d_signed (diversified generator from the note's address).
691        // Shared between diversified address integrity check and (future) note commitment.
692        let g_d_signed = NonIdentityPoint::new(
693            ecc_chip.clone(),
694            layouter.namespace(|| "witness g_d_signed"),
695            self.g_d_signed.as_ref().map(|gd| gd.to_affine()),
696        )?;
697
698        // Witness pk_d_signed (diversified transmission key). Used by condition 5 (address
699        // ownership) and condition 1 (signed note commitment).
700        let pk_d_signed = NonIdentityPoint::new(
701            ecc_chip.clone(),
702            layouter.namespace(|| "witness pk_d_signed"),
703            self.pk_d_signed
704                .as_ref()
705                .map(|pk_d_signed| pk_d_signed.inner().to_affine()),
706        )?;
707
708        // Witness nk (nullifier deriving key).
709        let nk = assign_free_advice(
710            layouter.namespace(|| "witness nk"),
711            config.advices[0],
712            self.nk.map(|nk| nk.inner()),
713        )?;
714
715        // Witness rho_signed.
716        // This is the nullifier of the note that was spent to create this note. It is
717        // a Nullifier type (a Pallas base field element) that serves as a unique, per-note domain
718        // separator.
719        // rho ensures that even if two notes have identical contents, they will produce
720        // different nullifiers because they were created by spending different input notes.
721        // rho provides deterministic, structural uniqueness. It is the nullifier of the
722        // spend input note so it chains each note to its creation context. A single tx
723        // can create multiple output notes from the same input. All those outputs share the same
724        // rho. If nullifier derivation only used rho (no psi), outputs from the same input could collide.
725        let rho_signed = assign_free_advice(
726            layouter.namespace(|| "witness rho_signed"),
727            config.advices[0],
728            self.rho_signed,
729        )?;
730
731        // Witness psi_signed.
732        // Pseudorandom field element derived from the note's random
733        // seed rseed and its nullifier domain separator rho.
734        // It adds randomness to the nullifier so that even if two notes share the same
735        // rho and nk, they produce different nullifiers.
736        // We provide it as input instead of deriving in-circuit since derivation
737        // would require an expensive Blake2b.
738        // psi provides randomized uniqueness. It is derived from rseed which is
739        // freshly random per note. So, even if multiple outputs are derived from the same note,
740        // different rseed values produce different psi values. But if uniqueness relied only on psi
741        // (i.e. only randomness), a faulty RNG would cause nullifier collisions. Together with rho,
742        // they cover each other's weaknesses.
743        // Additionally, there is a structural reason, if we only used psi, there would be an implicit chain:
744        // each note's identity is linked to the note that was spend to create it. The randomized psi
745        // breaks the chain, unblocking a requirement used in Orchard's security proof.
746        let psi_signed = assign_free_advice(
747            layouter.namespace(|| "witness psi_signed"),
748            config.advices[0],
749            self.psi_signed,
750        )?;
751
752        // Witness cm_signed as an ECC point, which is the form DeriveNullifier expects.
753        let cm_signed = Point::new(
754            ecc_chip.clone(),
755            layouter.namespace(|| "witness cm_signed"),
756            self.cm_signed.as_ref().map(|cm| cm.inner().to_affine()),
757        )?;
758
759        // ---------------------------------------------------------------
760        // Condition 2: Nullifier integrity.
761        // nf_signed = DeriveNullifier_nk(rho_signed, psi_signed, cm_signed)
762        // ---------------------------------------------------------------
763
764        // Nullifier integrity: derive nf_signed = DeriveNullifier(nk, rho_signed, psi_signed, cm_signed).
765        let nf_signed = derive_nullifier(
766            layouter
767                .namespace(|| "nf_signed = DeriveNullifier_nk(rho_signed, psi_signed, cm_signed)"),
768            config.poseidon_chip(),
769            config.add_chip(),
770            ecc_chip.clone(),
771            rho_signed.clone(), // clone so rho_signed remains available for note_commit
772            &psi_signed,
773            &cm_signed,
774            nk.clone(), // clone so nk remains available for commit_ivk
775        )?;
776
777        // Constrain nf_signed to equal the public input.
778        // Enforce that the nullifier computed inside the circuit matches the nullifier provided
779        // as a public input from outside the circuit (supplied at NF_SIGNED of the public input)
780        layouter.constrain_instance(nf_signed.inner().cell(), config.primary, NF_SIGNED)?;
781
782        // ---------------------------------------------------------------
783        // Condition 4: Spend authority.
784        // rk = [alpha] * SpendAuthG + ak_P
785        // ---------------------------------------------------------------
786
787        // Spend authority: proves that the public rk is a valid rerandomization of the prover's ak.
788        // The out-of-circuit verifier checks that the keystone signature is valid under rk,
789        // so this links the ZKP to the signature without revealing ak.
790        //
791        // Uses the shared gadget from crate::circuit::spend_authority – a 1:1 copy of
792        // the upstream Orchard spend authority check:
793        //   https://github.com/zcash/orchard/blob/main/src/circuit.rs#L542-L558
794        // Note: RK_X and RK_Y are public inputs.
795        crate::circuit::spend_authority::prove_spend_authority(
796            ecc_chip.clone(),
797            layouter.namespace(|| "cond4 spend authority"),
798            self.alpha,
799            &ak_P.clone().into(),
800            config.primary,
801            RK_X,
802            RK_Y,
803        )?;
804
805        // ---------------------------------------------------------------
806        // Condition 5: CommitIvk → ivk (internal wire, not a public input).
807        // pk_d_signed = [ivk] * g_d_signed.
808        // ---------------------------------------------------------------
809
810        // Diversified address integrity via shared address_ownership gadget.
811        // ivk = ⊥ or pk_d_signed = [ivk] * g_d_signed where ivk = CommitIvk_rivk(ExtractP(ak_P), nk).
812        // The ⊥ case is handled internally by CommitDomain::short_commit.
813        //
814        // Save ak cell before prove_address_ownership consumes it — we need it
815        // again below for deriving ivk_internal.
816        let ak = ak_P.extract_p().inner().clone();
817        let ak_for_internal = ak.clone();
818        let rivk = ScalarFixed::new(
819            ecc_chip.clone(),
820            layouter.namespace(|| "rivk"),
821            self.rivk.map(|rivk| rivk.inner()),
822        )?;
823        let ivk_cell = prove_address_ownership(
824            config.sinsemilla_chip_1(),
825            ecc_chip.clone(),
826            config.commit_ivk_chip(),
827            layouter.namespace(|| "cond5"),
828            "cond5",
829            ak,
830            nk.clone(),
831            rivk,
832            &g_d_signed,
833            &pk_d_signed,
834        )?;
835
836        // ---------------------------------------------------------------
837        // Derive ivk_internal = CommitIvk(ak, nk, rivk_internal).
838        // Used by Condition 11 for notes with internal (change) scope.
839        // ---------------------------------------------------------------
840        let ivk_internal_cell = {
841            use orchard::circuit::commit_ivk::gadgets::commit_ivk;
842            let rivk_internal = ScalarFixed::new(
843                ecc_chip.clone(),
844                layouter.namespace(|| "rivk_internal"),
845                self.rivk_internal.map(|rivk| rivk.inner()),
846            )?;
847            let ivk_internal = commit_ivk(
848                config.sinsemilla_chip_1(),
849                ecc_chip.clone(),
850                config.commit_ivk_chip(),
851                layouter.namespace(|| "commit_ivk_internal"),
852                ak_for_internal,
853                nk.clone(),
854                rivk_internal,
855            )?;
856            ivk_internal.inner().clone()
857        };
858
859        // ---------------------------------------------------------------
860        // Condition 1: Signed note commitment integrity.
861        // NoteCommit_rcm_signed(g_d_signed, pk_d_signed, 0, rho_signed, psi_signed) = cm_signed
862        // ---------------------------------------------------------------
863
864        // signed note commitment integrity.
865        // NoteCommit_rcm_signed(repr(g_d_signed), repr(pk_d_signed), 0,
866        //                        rho_signed, psi_signed) = cm_signed
867        // No null option: the signed note must have a valid commitment.
868        {
869            // Re-witness pk_d_signed for NoteCommit (need inner() from the constrained point).
870            let pk_d_signed_for_nc = NonIdentityPoint::new(
871                ecc_chip.clone(),
872                layouter.namespace(|| "pk_d_signed for note_commit"),
873                self.pk_d_signed
874                    .map(|pk_d_signed| pk_d_signed.inner().to_affine()),
875            )?;
876            // Copy-constrain to the condition-5 witness so both cells are bound to the same
877            // point. Without this, a malicious prover could supply a different point here;
878            // soundness is still preserved through the nf_signed public-input chain, but the
879            // explicit equality closes the gap and makes the intent unambiguous.
880            pk_d_signed_for_nc.constrain_equal(
881                layouter.namespace(|| "pk_d_signed_for_nc == pk_d_signed"),
882                &pk_d_signed,
883            )?;
884
885            let rcm_signed = ScalarFixed::new(
886                ecc_chip.clone(),
887                layouter.namespace(|| "rcm_signed"),
888                self.rcm_signed.as_ref().map(|rcm| rcm.inner()),
889            )?;
890
891            // The signed note's value is always 1 (ZIP §Dummy Signed Note).
892            // Value 1 ensures hardware wallets render the transaction on screen.
893            // The value is enforced transitively: v_signed feeds into NoteCommit -> cm_signed
894            // -> derive_nullifier -> nf_signed, which is constrained to the public input.
895            // Any different value would produce a different nf_signed, breaking the proof.
896            let v_signed = assign_free_advice(
897                layouter.namespace(|| "v_signed = 1"),
898                config.advices[0],
899                Value::known(NoteValue::from_raw(1)),
900            )?;
901
902            // Compute NoteCommit from witness data.
903            let derived_cm_signed = note_commit(
904                layouter.namespace(|| "NoteCommit_rcm_signed(g_d, pk_d, 1, rho, psi)"),
905                config.sinsemilla_chip_1(),
906                config.ecc_chip(),
907                config.note_commit_chip_signed(),
908                g_d_signed.inner(),
909                pk_d_signed_for_nc.inner(),
910                v_signed,
911                rho_signed.clone(),
912                psi_signed,
913                rcm_signed,
914            )?;
915
916            // Strict equality — no null/bottom option.
917            derived_cm_signed
918                .constrain_equal(layouter.namespace(|| "cm_signed integrity"), &cm_signed)?;
919        }
920
921        // ---------------------------------------------------------------
922        // Read shared public inputs from instance column.
923        // ---------------------------------------------------------------
924
925        // Rho binding (condition 3).
926        // rho_signed = Poseidon(cmx_1, cmx_2, cmx_3, cmx_4, cmx_5, van_comm, vote_round_id)
927        // Binds the signed note to the exact notes being delegated, the governance
928        // commitment, and the round, making the keystone signature non-replayable.
929        //
930        // Public inputs live in the instance column, but gates can only constrain
931        // advice cells. assign_advice_from_instance copies each public input into an
932        // advice cell with a copy constraint, so the prover cannot substitute a
933        // different value. The resulting cells are then passed into downstream gates.
934
935        // van_comm: used in condition 3 (rho binding hash) and condition 7 (gov
936        // commitment integrity check).
937        let van_comm_cell = layouter.assign_region(
938            || "copy van_comm from instance",
939            |mut region| {
940                region.assign_advice_from_instance(
941                    || "van_comm",
942                    config.primary,
943                    VAN_COMM,
944                    config.advices[0],
945                    0,
946                )
947            },
948        )?;
949
950        // vote_round_id: used in condition 3 (rho binding hash) and condition 7
951        // (gov commitment integrity check).
952        let vote_round_id_cell = layouter.assign_region(
953            || "copy vote_round_id from instance",
954            |mut region| {
955                region.assign_advice_from_instance(
956                    || "vote_round_id",
957                    config.primary,
958                    VOTE_ROUND_ID,
959                    config.advices[0],
960                    0,
961                )
962            },
963        )?;
964
965        // dom: the nullifier domain (ZIP §Nullifier Domains). Used in condition 14
966        // (alternate nullifier derivation). Derived out-of-circuit as
967        // Poseidon("governance authorization", vote_round_id).
968        let dom_cell = layouter.assign_region(
969            || "copy dom from instance",
970            |mut region| {
971                region.assign_advice_from_instance(
972                    || "dom",
973                    config.primary,
974                    DOM,
975                    config.advices[0],
976                    0,
977                )
978            },
979        )?;
980
981        // nc_root: the note commitment tree anchor. Each real note's Merkle root
982        // is checked against this in condition 10 (via q_per_note gate).
983        let nc_root_cell = layouter.assign_region(
984            || "copy nc_root from instance",
985            |mut region| {
986                region.assign_advice_from_instance(
987                    || "nc_root",
988                    config.primary,
989                    NC_ROOT,
990                    config.advices[0],
991                    0,
992                )
993            },
994        )?;
995
996        // nf_imt_root: the nullifier IMT root at snapshot height. Each note's IMT
997        // non-membership proof root is checked against this in condition 13
998        // (via q_per_note gate).
999        let nf_imt_root_cell = layouter.assign_region(
1000            || "copy nf_imt_root from instance",
1001            |mut region| {
1002                region.assign_advice_from_instance(
1003                    || "nf_imt_root",
1004                    config.primary,
1005                    NF_IMT_ROOT,
1006                    config.advices[0],
1007                    0,
1008                )
1009            },
1010        )?;
1011
1012        // ---------------------------------------------------------------
1013        // Conditions 9–15: prove ownership and unspentness of each delegated note.
1014        // ---------------------------------------------------------------
1015
1016        // For each of the 5 note slots, synthesize_note_slot proves:
1017        //   - I know the note's contents and it has a valid commitment (cond 9)
1018        //   - The commitment exists in the mainchain note tree (cond 10)
1019        //   - The note belongs to my key (cond 11)
1020        //   - The note's nullifier is NOT in the spent-nullifier IMT (cond 12-13)
1021        //   - A governance nullifier is correctly derived for this note (cond 14)
1022        //   - Padded (unused) slots have zero value (cond 15)
1023        //
1024        // Returns three values per slot for use in the global conditions that follow:
1025        //   cmx_i      — hashed into rho_signed (condition 3)
1026        //   v_i        — summed into v_total (conditions 7 and 8)
1027        //   gov_null_i — exposed as public input
1028
1029        let mut cmx_cells = Vec::with_capacity(5);
1030        let mut v_cells = Vec::with_capacity(5);
1031        let mut gov_null_cells = Vec::with_capacity(5);
1032
1033        for i in 0..5 {
1034            let (cmx_i, v_i, gov_null_i) = synthesize_note_slot(
1035                &config,
1036                &mut layouter,
1037                ecc_chip.clone(),
1038                &ivk_cell,
1039                &ivk_internal_cell,
1040                &nk,
1041                &dom_cell,
1042                &nc_root_cell,
1043                &nf_imt_root_cell,
1044                &self.notes[i],
1045                i,
1046                GOV_NULL_OFFSETS[i],
1047            )?;
1048            cmx_cells.push(cmx_i);
1049            v_cells.push(v_i);
1050            gov_null_cells.push(gov_null_i);
1051        }
1052
1053        // ---------------------------------------------------------------
1054        // Condition 3: Rho binding.
1055        // rho_signed = Poseidon(cmx_1, cmx_2, cmx_3, cmx_4, cmx_5, van_comm, vote_round_id)
1056        // ---------------------------------------------------------------
1057
1058        // The keystone note's rho is deterministically derived from the 5 note
1059        // commitments, the gov commitment, and the vote round. This binds the
1060        // keystone signature to the exact set of notes being delegated — replaying
1061        // the signature with different notes would produce a different rho, which
1062        // would change the nullifier (cond 2) and break the proof.
1063        {
1064            // Hash the 7 inputs: 5 note commitment x-coords (from cond 9),
1065            // van_comm (public input), and vote_round_id (public input).
1066            let poseidon_message = [
1067                cmx_cells[0].clone(),
1068                cmx_cells[1].clone(),
1069                cmx_cells[2].clone(),
1070                cmx_cells[3].clone(),
1071                cmx_cells[4].clone(),
1072                van_comm_cell.clone(),
1073                vote_round_id_cell.clone(),
1074            ];
1075            let poseidon_hasher = PoseidonHash::<
1076                pallas::Base,
1077                _,
1078                poseidon::P128Pow5T3,
1079                ConstantLength<7>,
1080                3,
1081                2,
1082            >::init(
1083                config.poseidon_chip(),
1084                layouter.namespace(|| "rho binding Poseidon init"),
1085            )?;
1086            let derived_rho = poseidon_hasher.hash(
1087                layouter.namespace(|| "Poseidon(cmx_1..5, van_comm, vote_round_id)"),
1088                poseidon_message,
1089            )?;
1090
1091            // The derived rho must equal the rho_signed used in condition 1 (note
1092            // commitment) and condition 2 (nullifier). This closes the binding.
1093            layouter.assign_region(
1094                || "rho binding equality",
1095                |mut region| region.constrain_equal(derived_rho.cell(), rho_signed.cell()),
1096            )?;
1097        }
1098
1099        // ---------------------------------------------------------------
1100        // Condition 6: Output note commitment integrity.
1101        // Returns (g_d_new_x, pk_d_new_x) for condition 7.
1102        // ---------------------------------------------------------------
1103
1104        // Output note commitment integrity (condition 6).
1105        //
1106        // ExtractP(NoteCommit_rcm_new(repr(g_d_new), repr(pk_d_new), 0,
1107        //          rho_new, psi_new)) ∈ {cmx_new, ⊥}
1108        //
1109        // where rho_new = nf_signed (the nullifier derived in condition 2).
1110        //
1111        // The output address (g_d_new, pk_d_new) is NOT checked against ivk.
1112        // The voting hotkey is bound transitively through van_comm (condition 7)
1113        // which is hashed into rho_signed (condition 3), so the keystone
1114        // signature authenticates the output address without an in-circuit check.
1115        //
1116        // Returns g_d_new_x and pk_d_new_x for reuse in condition 7.
1117        let (g_d_new_x, pk_d_new_x) = {
1118            // Witness g_d_new (diversified generator of the output note's address).
1119            let g_d_new = NonIdentityPoint::new(
1120                ecc_chip.clone(),
1121                layouter.namespace(|| "witness g_d_new"),
1122                self.g_d_new.as_ref().map(|gd| gd.to_affine()),
1123            )?;
1124
1125            // Witness pk_d_new (diversified transmission key of the output note's address).
1126            let pk_d_new = NonIdentityPoint::new(
1127                ecc_chip.clone(),
1128                layouter.namespace(|| "witness pk_d_new"),
1129                self.pk_d_new.map(|pk_d_new| pk_d_new.inner().to_affine()),
1130            )?;
1131
1132            // rho_new = nf_signed: the output note's rho is chained from the
1133            // signed note's nullifier. This reuses the same cell that was
1134            // constrained to the public input in condition 2.
1135            let rho_new = nf_signed.inner().clone();
1136
1137            // Witness psi_new.
1138            let psi_new = assign_free_advice(
1139                layouter.namespace(|| "witness psi_new"),
1140                config.advices[0],
1141                self.psi_new,
1142            )?;
1143
1144            let rcm_new = ScalarFixed::new(
1145                ecc_chip.clone(),
1146                layouter.namespace(|| "rcm_new"),
1147                self.rcm_new.as_ref().map(|rcm_new| rcm_new.inner()),
1148            )?;
1149
1150            // The output note's value is always 0.
1151            // Zero is enforced transitively: v_new feeds into NoteCommit -> cm_new,
1152            // whose x-coordinate is constrained to the CMX_NEW public input.
1153            // Any non-zero value would produce a different cmx, breaking the proof.
1154            let v_new = assign_free_advice(
1155                layouter.namespace(|| "v_new = 0"),
1156                config.advices[0],
1157                Value::known(NoteValue::ZERO),
1158            )?;
1159
1160            // Compute NoteCommit for the output note using the second chip pair.
1161            let cm_new = note_commit(
1162                layouter.namespace(|| "NoteCommit_rcm_new(g_d_new, pk_d_new, 0, rho_new, psi_new)"),
1163                config.sinsemilla_chip_2(),
1164                config.ecc_chip(),
1165                config.note_commit_chip_new(),
1166                g_d_new.inner(),
1167                pk_d_new.inner(),
1168                v_new,
1169                rho_new,
1170                psi_new,
1171                rcm_new,
1172            )?;
1173
1174            // Extract the x-coordinate of the commitment point.
1175            let cmx = cm_new.extract_p();
1176
1177            // Constrain cmx to equal the public input.
1178            layouter.constrain_instance(cmx.inner().cell(), config.primary, CMX_NEW)?;
1179
1180            // Extract x-coordinates of the output address for condition 7.
1181            (
1182                g_d_new.extract_p().inner().clone(),
1183                pk_d_new.extract_p().inner().clone(),
1184            )
1185        };
1186
1187        // ---------------------------------------------------------------
1188        // Compute v_total = v_1 + v_2 + v_3 + v_4 + v_5 (used by conditions 7 & 8).
1189        // ---------------------------------------------------------------
1190
1191        // v_total = v_1 + v_2 + v_3 + v_4 + v_5  (four AddChip additions)
1192        let add_chip = config.add_chip();
1193        let sum_12 = add_chip.add(layouter.namespace(|| "v_1 + v_2"), &v_cells[0], &v_cells[1])?;
1194        let sum_123 = add_chip.add(
1195            layouter.namespace(|| "(v_1 + v_2) + v_3"),
1196            &sum_12,
1197            &v_cells[2],
1198        )?;
1199        let sum_1234 = add_chip.add(
1200            layouter.namespace(|| "(v_1 + v_2 + v_3) + v_4"),
1201            &sum_123,
1202            &v_cells[3],
1203        )?;
1204        let v_total = add_chip.add(
1205            layouter.namespace(|| "(v_1 + v_2 + v_3 + v_4) + v_5"),
1206            &sum_1234,
1207            &v_cells[4],
1208        )?;
1209
1210        // ---------------------------------------------------------------
1211        // Condition 8: Ballot scaling.
1212        // num_ballots = floor(v_total / BALLOT_DIVISOR)
1213        // Proved by: num_ballots * BALLOT_DIVISOR + remainder == v_total,
1214        //            range checks on num_ballots and remainder,
1215        //            and a non-zero check on num_ballots.
1216        // ---------------------------------------------------------------
1217
1218        // Ballot scaling (condition 8).
1219        //
1220        // Converts the raw zatoshi balance into a ballot count via floor-division:
1221        //   num_ballots = floor(v_total / 12,500,000)
1222        //
1223        // Constraints:
1224        //   1. num_ballots * BALLOT_DIVISOR + remainder == v_total
1225        //   2. remainder < 2^24   (24-bit range check via shift-by-2^6)
1226        //   3. 0 < num_ballots <= 2^30  (via nb_minus_one 30-bit range check)
1227        //
1228        // Range check implementation: the lookup table operates in 10-bit words,
1229        // so it directly checks multiples of 10 bits. For remainder (24 bits),
1230        // we multiply by 2^6 before a 30-bit check. For num_ballots, 30 bits is
1231        // already a multiple of 10, so nb_minus_one is checked directly with
1232        // 3 words — no shift needed. 2^30 ballots × 0.125 ZEC ≈ 134M ZEC,
1233        // well above the 21M ZEC supply, so 30 bits is a safe upper bound.
1234        //
1235        // The nb_minus_one check simultaneously enforces both the upper bound
1236        // and non-zero: if nb_minus_one < 2^30 then num_ballots ∈ [1, 2^30].
1237        // If num_ballots = 0, nb_minus_one wraps to p-1 ≈ 2^254, failing the check.
1238        let num_ballots = {
1239            // Witness num_ballots and remainder as free advice.
1240            let num_ballots = assign_free_advice(
1241                layouter.namespace(|| "witness num_ballots"),
1242                config.advices[0],
1243                self.num_ballots,
1244            )?;
1245
1246            let remainder = assign_free_advice(
1247                layouter.namespace(|| "witness remainder"),
1248                config.advices[0],
1249                self.remainder,
1250            )?;
1251
1252            // Assign the BALLOT_DIVISOR constant (baked into verification key).
1253            let ballot_divisor = assign_constant(
1254                layouter.namespace(|| "BALLOT_DIVISOR constant"),
1255                config.advices[0],
1256                pallas::Base::from(BALLOT_DIVISOR),
1257            )?;
1258
1259            // product = num_ballots * BALLOT_DIVISOR
1260            let product = config.mul_chip().mul(
1261                layouter.namespace(|| "num_ballots * BALLOT_DIVISOR"),
1262                &num_ballots,
1263                &ballot_divisor,
1264            )?;
1265
1266            // reconstructed = product + remainder
1267            let reconstructed = config.add_chip().add(
1268                layouter.namespace(|| "product + remainder"),
1269                &product,
1270                &remainder,
1271            )?;
1272
1273            // Constrain: reconstructed == v_total
1274            layouter.assign_region(
1275                || "num_ballots * BALLOT_DIVISOR + remainder == v_total",
1276                |mut region| region.constrain_equal(reconstructed.cell(), v_total.cell()),
1277            )?;
1278
1279            // Range check remainder to [0, 2^24).
1280            // 24 is not a multiple of 10, so we multiply by 2^(30-24) = 2^6 = 64
1281            // and range-check the shifted value to 30 bits (3 words × 10 bits).
1282            // If remainder >= 2^24, then remainder * 64 >= 2^30, failing the check.
1283            let shift_6 = assign_constant(
1284                layouter.namespace(|| "2^6 shift constant"),
1285                config.advices[0],
1286                pallas::Base::from(1u64 << 6),
1287            )?;
1288            let remainder_shifted = config.mul_chip().mul(
1289                layouter.namespace(|| "remainder * 2^6"),
1290                &remainder,
1291                &shift_6,
1292            )?;
1293            config.range_check_config().copy_check(
1294                layouter.namespace(|| "remainder * 2^6 < 2^30 (i.e. remainder < 2^24)"),
1295                remainder_shifted,
1296                3,    // num_words: 3 * 10 = 30 bits
1297                true, // strict: running sum terminates at 0
1298            )?;
1299
1300            // Non-zero and upper bound: 0 < num_ballots <= 2^30.
1301            // Witness nb_minus_one = num_ballots - 1 and constrain
1302            // nb_minus_one + 1 == num_ballots. Range-check nb_minus_one
1303            // directly to 30 bits (3 words × 10 — no shift needed).
1304            // This single check enforces both bounds: if nb_minus_one < 2^30
1305            // then num_ballots ∈ [1, 2^30]. If num_ballots = 0, nb_minus_one
1306            // wraps to p - 1 ≈ 2^254, which fails the range check.
1307            let one = assign_constant(
1308                layouter.namespace(|| "one constant"),
1309                config.advices[0],
1310                pallas::Base::one(),
1311            )?;
1312
1313            let nb_minus_one = num_ballots.value().map(|v| *v - pallas::Base::one());
1314            let nb_minus_one = assign_free_advice(
1315                layouter.namespace(|| "witness nb_minus_one"),
1316                config.advices[0],
1317                nb_minus_one,
1318            )?;
1319
1320            let nb_recomputed = config.add_chip().add(
1321                layouter.namespace(|| "nb_minus_one + 1"),
1322                &nb_minus_one,
1323                &one,
1324            )?;
1325            layouter.assign_region(
1326                || "nb_minus_one + 1 == num_ballots",
1327                |mut region| region.constrain_equal(nb_recomputed.cell(), num_ballots.cell()),
1328            )?;
1329
1330            config.range_check_config().copy_check(
1331                layouter.namespace(|| "nb_minus_one < 2^30"),
1332                nb_minus_one,
1333                3,    // num_words: 3 * 10 = 30 bits
1334                true, // strict: running sum terminates at 0
1335            )?;
1336
1337            num_ballots
1338        };
1339
1340        // ---------------------------------------------------------------
1341        // Condition 7: Gov commitment integrity.
1342        // van_comm_core = Poseidon(DOMAIN_VAN, g_d_new_x, pk_d_new_x, num_ballots,
1343        //                          vote_round_id, MAX_PROPOSAL_AUTHORITY)
1344        // van_comm = Poseidon(van_comm_core, van_comm_rand)
1345        // ---------------------------------------------------------------
1346
1347        // Gov commitment integrity (condition 7).
1348        //
1349        // van_comm_core = Poseidon(DOMAIN_VAN, g_d_new_x, pk_d_new_x, num_ballots,
1350        //                          vote_round_id, MAX_PROPOSAL_AUTHORITY)
1351        // van_comm = Poseidon(van_comm_core, van_comm_rand)
1352        //
1353        // Proves that the governance commitment (public input) is correctly derived
1354        // from the domain tag, the output note's voting hotkey address, the ballot
1355        // count (floor-divided from v_total), the vote round identifier, a blinding
1356        // factor, and the proposal authority bitmask (MAX_PROPOSAL_AUTHORITY = 65535
1357        // for full authority).
1358        //
1359        // Uses two Poseidon invocations over even arities (6 then 2).
1360        {
1361            let van_comm_rand = assign_free_advice(
1362                layouter.namespace(|| "witness van_comm_rand"),
1363                config.advices[0],
1364                self.van_comm_rand,
1365            )?;
1366
1367            // DOMAIN_VAN — domain tag for Vote Authority Notes. Provides domain
1368            // separation from Vote Commitments in the shared vote commitment tree.
1369            let domain_van = assign_constant(
1370                layouter.namespace(|| "DOMAIN_VAN constant"),
1371                config.advices[0],
1372                pallas::Base::from(van_integrity::DOMAIN_VAN),
1373            )?;
1374
1375            // MAX_PROPOSAL_AUTHORITY — baked into the verification key so the
1376            // prover cannot alter it.
1377            let max_proposal_authority = assign_constant(
1378                layouter.namespace(|| "MAX_PROPOSAL_AUTHORITY constant"),
1379                config.advices[0],
1380                pallas::Base::from(MAX_PROPOSAL_AUTHORITY),
1381            )?;
1382
1383            // Two-layer Poseidon hash via the shared VAN integrity gadget.
1384            // Uses num_ballots (from condition 8) instead of v_total.
1385            let derived_van_comm = van_integrity::van_integrity_poseidon(
1386                &config.poseidon_config,
1387                &mut layouter,
1388                "Gov commitment",
1389                domain_van,
1390                g_d_new_x,
1391                pk_d_new_x,
1392                num_ballots,
1393                vote_round_id_cell,
1394                max_proposal_authority,
1395                van_comm_rand,
1396            )?;
1397
1398            // Constrain: derived_van_comm == van_comm (from condition 3).
1399            layouter.assign_region(
1400                || "van_comm integrity",
1401                |mut region| region.constrain_equal(derived_van_comm.cell(), van_comm_cell.cell()),
1402            )?;
1403        }
1404        Ok(())
1405    }
1406}
1407
1408// ================================================================
1409// Per-note slot synthesis (conditions 9–14).
1410// ================================================================
1411
1412/// Synthesize conditions 9–14 for a single note slot.
1413///
1414/// Returns `(cmx_cell, v_cell, gov_null_cell)` — the extracted commitment,
1415/// value, and governance nullifier for use in the rho binding (condition 3),
1416/// gov commitment (condition 7), and gov nullifier (public input).
1417#[allow(clippy::too_many_arguments, non_snake_case)]
1418fn synthesize_note_slot(
1419    config: &Config,
1420    layouter: &mut impl Layouter<pallas::Base>,
1421    ecc_chip: EccChip<OrchardFixedBases>,
1422    ivk_cell: &AssignedCell<pallas::Base, pallas::Base>,
1423    ivk_internal_cell: &AssignedCell<pallas::Base, pallas::Base>,
1424    nk_cell: &AssignedCell<pallas::Base, pallas::Base>,
1425    dom_cell: &AssignedCell<pallas::Base, pallas::Base>,
1426    nc_root_cell: &AssignedCell<pallas::Base, pallas::Base>,
1427    nf_imt_root_cell: &AssignedCell<pallas::Base, pallas::Base>,
1428    note: &NoteSlotWitness,
1429    slot: usize,
1430    gov_null_offset: usize,
1431) -> Result<
1432    (
1433        AssignedCell<pallas::Base, pallas::Base>,
1434        AssignedCell<pallas::Base, pallas::Base>,
1435        AssignedCell<pallas::Base, pallas::Base>,
1436    ),
1437    plonk::Error,
1438> {
1439    let s = slot; // shorthand for format strings
1440
1441    // ---------------------------------------------------------------
1442    // Condition 9: Note commitment integrity.
1443    // ---------------------------------------------------------------
1444
1445    // Proves the prover knows the note's plaintext (address, value, rho, psi)
1446    // and that it hashes to the claimed commitment. This is the foundation —
1447    // all other per-note conditions build on these witnessed values.
1448
1449    // Witness the note's address components as curve points.
1450    let g_d = NonIdentityPoint::new(
1451        ecc_chip.clone(),
1452        layouter.namespace(|| format!("note {s} witness g_d")),
1453        note.g_d.as_ref().map(|gd| gd.to_affine()),
1454    )?;
1455
1456    let pk_d = NonIdentityPoint::new(
1457        ecc_chip.clone(),
1458        layouter.namespace(|| format!("note {s} witness pk_d")),
1459        note.pk_d.as_ref().map(|pk| pk.to_affine()),
1460    )?;
1461
1462    // Witness the note's value, rho, and psi as field elements.
1463    let v = assign_free_advice(
1464        layouter.namespace(|| format!("note {s} witness v")),
1465        config.advices[0],
1466        note.v,
1467    )?;
1468
1469    let rho = assign_free_advice(
1470        layouter.namespace(|| format!("note {s} witness rho")),
1471        config.advices[0],
1472        note.rho,
1473    )?;
1474
1475    let psi = assign_free_advice(
1476        layouter.namespace(|| format!("note {s} witness psi")),
1477        config.advices[0],
1478        note.psi,
1479    )?;
1480
1481    // Witness rcm (commitment randomness) as a fixed-base scalar for ECC.
1482    let rcm = ScalarFixed::new(
1483        ecc_chip.clone(),
1484        layouter.namespace(|| format!("note {s} rcm")),
1485        note.rcm.as_ref().map(|rcm| rcm.inner()),
1486    )?;
1487
1488    // Witness the claimed commitment as a curve point.
1489    let cm = Point::new(
1490        ecc_chip.clone(),
1491        layouter.namespace(|| format!("note {s} witness cm")),
1492        note.cm.as_ref().map(|cm| cm.inner().to_affine()),
1493    )?;
1494
1495    // Recompute NoteCommit from the plaintext and constrain it equals the
1496    // witnessed cm. If any input (g_d, pk_d, v, rho, psi, rcm) is wrong,
1497    // the recomputed commitment won't match and the proof fails.
1498    let derived_cm = note_commit(
1499        layouter.namespace(|| format!("note {s} NoteCommit")),
1500        config.sinsemilla_chip_1(),
1501        config.ecc_chip(),
1502        config.note_commit_chip_signed(),
1503        g_d.inner(),
1504        pk_d.inner(),
1505        v.clone(),
1506        rho.clone(),
1507        psi.clone(),
1508        rcm,
1509    )?;
1510
1511    derived_cm.constrain_equal(layouter.namespace(|| format!("note {s} cm integrity")), &cm)?;
1512
1513    // cmx = ExtractP(cm) — returned to caller.
1514    let cmx_cell = cm.extract_p().inner().clone();
1515
1516    // Witness v as pallas::Base for use in the gov commitment sum (condition 7).
1517    // Constrain it equal to the NoteValue cell used in note_commit.
1518    let v_base = assign_free_advice(
1519        layouter.namespace(|| format!("note {s} witness v_base")),
1520        config.advices[0],
1521        note.v.map(|val| pallas::Base::from(val.inner())),
1522    )?;
1523    layouter.assign_region(
1524        || format!("note {s} v = v_base"),
1525        |mut region| region.constrain_equal(v.cell(), v_base.cell()),
1526    )?;
1527
1528    // ---------------------------------------------------------------
1529    // Condition 11: Diversified address integrity (scope-aware).
1530    // pk_d = [selected_ivk] * g_d
1531    // where selected_ivk = ivk (external) or ivk_internal, based on is_internal.
1532    // ---------------------------------------------------------------
1533
1534    // Proves this note belongs to the prover's key. External notes use ivk
1535    // (derived from rivk in condition 5); internal (change) notes use
1536    // ivk_internal (derived from rivk_internal). The q_scope_select gate
1537    // constrains the mux: selected_ivk = ivk + is_internal * (ivk_internal - ivk).
1538
1539    // Witness the is_internal flag for this note.
1540    let is_internal = assign_free_advice(
1541        layouter.namespace(|| format!("note {s} witness is_internal")),
1542        config.advices[0],
1543        note.is_internal.map(|b| pallas::Base::from(b as u64)),
1544    )?;
1545
1546    // Mux between ivk and ivk_internal using the q_scope_select custom gate.
1547    let selected_ivk = layouter.assign_region(
1548        || format!("note {s} scope ivk select"),
1549        |mut region| {
1550            config.q_scope_select.enable(&mut region, 0)?;
1551
1552            is_internal.copy_advice(|| "is_internal", &mut region, config.advices[0], 0)?;
1553            ivk_cell.copy_advice(|| "ivk", &mut region, config.advices[1], 0)?;
1554            ivk_internal_cell.copy_advice(|| "ivk_internal", &mut region, config.advices[2], 0)?;
1555
1556            // Compute the muxed value: ivk + is_internal * (ivk_internal - ivk)
1557            let selected = ivk_cell
1558                .value()
1559                .zip(ivk_internal_cell.value())
1560                .zip(is_internal.value())
1561                .map(|((ivk, ivk_int), flag)| {
1562                    if *flag == pallas::Base::one() {
1563                        *ivk_int
1564                    } else {
1565                        *ivk
1566                    }
1567                });
1568            region.assign_advice(|| "selected_ivk", config.advices[3], 0, || selected)
1569        },
1570    )?;
1571
1572    // Convert selected_ivk to a scalar for ECC multiplication.
1573    let ivk_scalar = ScalarVar::from_base(
1574        ecc_chip.clone(),
1575        layouter.namespace(|| format!("note {s} selected_ivk to scalar")),
1576        &selected_ivk,
1577    )?;
1578
1579    // Compute [selected_ivk] * g_d and check it matches the witnessed pk_d.
1580    let (derived_pk_d, _ivk) = g_d.mul(
1581        layouter.namespace(|| format!("note {s} [selected_ivk] g_d")),
1582        ivk_scalar,
1583    )?;
1584
1585    // Constrain: derived_pk_d == pk_d.
1586    derived_pk_d.constrain_equal(
1587        layouter.namespace(|| format!("note {s} pk_d equality")),
1588        &pk_d,
1589    )?;
1590
1591    // ---------------------------------------------------------------
1592    // Condition 12: Private nullifier derivation.
1593    // real_nf = DeriveNullifier_nk(rho, psi, cm)
1594    // ---------------------------------------------------------------
1595
1596    // Derives the note's real mainchain nullifier in-circuit. This is NOT
1597    // published — it stays private. It's used for two things:
1598    //   1. IMT non-membership (cond 13): proves the note is unspent
1599    //   2. Gov nullifier derivation (cond 14): hashed into the public gov_null
1600
1601    let real_nf = derive_nullifier(
1602        layouter.namespace(|| format!("note {s} real_nf = DeriveNullifier")),
1603        config.poseidon_chip(),
1604        config.add_chip(),
1605        ecc_chip.clone(),
1606        rho.clone(),
1607        &psi,
1608        &cm,
1609        nk_cell.clone(),
1610    )?;
1611
1612    // ---------------------------------------------------------------
1613    // Condition 14: Alternate nullifier integrity.
1614    // nf_dom = Poseidon(nk, dom, real_nf)
1615    // ---------------------------------------------------------------
1616
1617    // Derives an alternate nullifier published on the vote chain to prevent
1618    // double-delegation (ZIP §Alternate Nullifier Derivation). Single
1619    // ConstantLength<3> Poseidon hash (2 permutations at rate=2) that:
1620    //   - Is keyed by nk, so it can't be linked to real_nf even when real_nf is
1621    //     later revealed on mainchain
1622    //   - Is scoped to this application instance via dom (a public input derived
1623    //     out-of-circuit from the protocol identifier and vote_round_id)
1624    //
1625    // The result is constrained to the public instance so the vote chain can
1626    // track which notes have already been delegated this round.
1627
1628    // Poseidon(nk, dom, real_nf)
1629    let gov_null = {
1630        let poseidon_hasher =
1631            PoseidonHash::<pallas::Base, _, poseidon::P128Pow5T3, ConstantLength<3>, 3, 2>::init(
1632                config.poseidon_chip(),
1633                layouter.namespace(|| format!("note {s} gov_null init")),
1634            )?;
1635        poseidon_hasher.hash(
1636            layouter.namespace(|| format!("note {s} Poseidon(nk, dom, real_nf)")),
1637            [nk_cell.clone(), dom_cell.clone(), real_nf.inner().clone()],
1638        )?
1639    };
1640
1641    // Constrain gov_null to the public instance column so the vote chain sees it.
1642    let gov_null_cell = gov_null.clone();
1643    layouter.constrain_instance(gov_null.cell(), config.primary, gov_null_offset)?;
1644
1645    // ---------------------------------------------------------------
1646    // Condition 10: Merkle path validity.
1647    // ---------------------------------------------------------------
1648
1649    // Proves the note's commitment exists in the mainchain note commitment tree.
1650    // Computes the Sinsemilla-based Merkle root from the leaf (cmx = ExtractP(cm))
1651    // and the 32-level authentication path. The q_per_note gate then checks that
1652    // the computed root equals the public nc_root (for real notes only).
1653
1654    let root = {
1655        // Convert the witnessed Merkle path siblings to raw field elements.
1656        let path = note
1657            .path
1658            .map(|typed_path| typed_path.map(|node| node.inner()));
1659        let merkle_inputs = GadgetMerklePath::construct(
1660            [config.merkle_chip_1(), config.merkle_chip_2()],
1661            OrchardHashDomains::MerkleCrh,
1662            note.pos,
1663            path,
1664        );
1665        // The leaf is the x-coordinate of the note commitment.
1666        let leaf = cm.extract_p().inner().clone();
1667        merkle_inputs
1668            .calculate_root(layouter.namespace(|| format!("note {s} Merkle path")), leaf)?
1669    };
1670
1671    // ---------------------------------------------------------------
1672    // Condition 13: IMT non-membership.
1673    // ---------------------------------------------------------------
1674
1675    let imt_root = synthesize_imt_non_membership(
1676        &config.imt_config,
1677        &config.poseidon_config,
1678        &config.ecc_config,
1679        layouter,
1680        note.imt_nf_bounds,
1681        note.imt_leaf_pos,
1682        note.imt_path,
1683        real_nf.inner(),
1684        s,
1685    )?;
1686
1687    // ---------------------------------------------------------------
1688    // Custom gate region: conditions 10 + 13.
1689    // ---------------------------------------------------------------
1690
1691    // Activates the q_per_note gate, which ties together results from the
1692    // preceding conditions into a single row of checks:
1693    //   - Cond 10: v * (root - nc_root) = 0 — Merkle membership (skipped for dummy notes)
1694    //   - Cond 13: IMT root must match public nf_imt_root
1695    //
1696    // All five values are copied from earlier regions via copy constraints,
1697    // so the gate operates on the same cells that the upstream gadgets produced.
1698
1699    layouter.assign_region(
1700        || format!("note {s} per-note checks"),
1701        |mut region| {
1702            config.q_per_note.enable(&mut region, 0)?;
1703
1704            v.copy_advice(|| "v", &mut region, config.advices[0], 0)?;
1705            root.copy_advice(|| "calculated root", &mut region, config.advices[1], 0)?;
1706            nc_root_cell.copy_advice(|| "nc_root (anchor)", &mut region, config.advices[2], 0)?;
1707            imt_root.copy_advice(|| "imt_root", &mut region, config.advices[3], 0)?;
1708            nf_imt_root_cell.copy_advice(|| "nf_imt_root", &mut region, config.advices[4], 0)?;
1709
1710            Ok(())
1711        },
1712    )?;
1713
1714    // Return the three values needed by global conditions:
1715    //   cmx_cell   → condition 3 (rho binding hash)
1716    //   v_base     → conditions 7 & 8 (gov commitment, min weight)
1717    //   gov_null   → exposed as public input
1718    Ok((cmx_cell, v_base, gov_null_cell))
1719}
1720
1721// ================================================================
1722// Instance
1723// ================================================================
1724
1725/// Public inputs to the delegation circuit (14 field elements).
1726///
1727/// These are the values posted to the vote chain (§2.4) that both the prover
1728/// and verifier agree on. The verifier checks the proof against these values
1729/// without seeing any private witnesses.
1730#[derive(Clone, Debug)]
1731pub struct Instance {
1732    /// The derived nullifier of the keystone note.
1733    pub nf_signed: Nullifier,
1734    /// The randomized spend validating key.
1735    pub rk: VerificationKey<SpendAuth>,
1736    /// The extracted commitment of the output note.
1737    pub cmx_new: pallas::Base,
1738    /// The governance commitment hash.
1739    pub van_comm: pallas::Base,
1740    /// The voting round identifier.
1741    pub vote_round_id: pallas::Base,
1742    /// The note commitment tree root (shared anchor).
1743    pub nc_root: pallas::Base,
1744    /// The nullifier IMT root.
1745    pub nf_imt_root: pallas::Base,
1746    /// Per-note governance nullifiers (5 slots).
1747    pub gov_null: [pallas::Base; 5],
1748    /// The nullifier domain (ZIP §Nullifier Domains).
1749    pub dom: pallas::Base,
1750}
1751
1752impl Instance {
1753    /// Constructs an [`Instance`] from its constituent parts.
1754    pub fn from_parts(
1755        nf_signed: Nullifier,
1756        rk: VerificationKey<SpendAuth>,
1757        cmx_new: pallas::Base,
1758        van_comm: pallas::Base,
1759        vote_round_id: pallas::Base,
1760        nc_root: pallas::Base,
1761        nf_imt_root: pallas::Base,
1762        gov_null: [pallas::Base; 5],
1763        dom: pallas::Base,
1764    ) -> Self {
1765        Instance {
1766            nf_signed,
1767            rk,
1768            cmx_new,
1769            van_comm,
1770            vote_round_id,
1771            nc_root,
1772            nf_imt_root,
1773            gov_null,
1774            dom,
1775        }
1776    }
1777
1778    /// Serializes the public inputs into the flat field-element vector that
1779    /// halo2's `MockProver::run`, `create_proof`, and `verify_proof` expect.
1780    ///
1781    /// The order must match the instance column offsets defined at the top of
1782    /// this file (`NF_SIGNED`, `RK_X`, `RK_Y`, `CMX_NEW`, etc.).
1783    pub fn to_halo2_instance(&self) -> Vec<vesta::Scalar> {
1784        // rk is stored as compressed bytes but the circuit constrains it as
1785        // two field elements (x, y coordinates of the curve point).
1786        // Safety: VerificationKey<SpendAuth> guarantees a valid, non-identity
1787        // curve point, so both conversions are infallible.
1788        let rk = pallas::Point::from_bytes(&self.rk.clone().into())
1789            .expect("rk is a valid curve point (guaranteed by VerificationKey)")
1790            .to_affine()
1791            .coordinates()
1792            .expect("rk is not the identity point (guaranteed by VerificationKey)");
1793
1794        vec![
1795            self.nf_signed.inner(),
1796            *rk.x(),
1797            *rk.y(),
1798            self.cmx_new,
1799            self.van_comm,
1800            self.vote_round_id,
1801            self.nc_root,
1802            self.nf_imt_root,
1803            self.gov_null[0],
1804            self.gov_null[1],
1805            self.gov_null[2],
1806            self.gov_null[3],
1807            self.gov_null[4],
1808            self.dom,
1809        ]
1810    }
1811}
1812
1813// ================================================================
1814// Test-only
1815// ================================================================
1816
1817#[cfg(test)]
1818mod tests {
1819    use super::*;
1820    use crate::delegation::imt::{
1821        derive_nullifier_domain, gov_null_hash, ImtProofData, ImtProvider, SpacedLeafImtProvider,
1822    };
1823    use ff::Field;
1824    use halo2_proofs::dev::MockProver;
1825    use incrementalmerkletree::{Hashable, Level};
1826    use orchard::{
1827        keys::{FullViewingKey, Scope, SpendValidatingKey, SpendingKey},
1828        note::{commitment::ExtractedNoteCommitment, Note, Rho},
1829    };
1830    use pasta_curves::{arithmetic::CurveAffine, pallas};
1831    use rand::rngs::OsRng;
1832    use std::string::{String, ToString};
1833
1834    // Re-use the public K constant from the circuit module.
1835    use super::K;
1836
1837    /// Helper: build a NoteSlotWitness for a note with a Merkle path and IMT proof.
1838    fn make_note_slot(
1839        note: &Note,
1840        auth_path: &[MerkleHashOrchard; MERKLE_DEPTH_ORCHARD],
1841        pos: u32,
1842        imt: &ImtProofData,
1843        is_internal: bool,
1844    ) -> NoteSlotWitness {
1845        let rho = note.rho();
1846        let psi = note.rseed().psi(&rho);
1847        let rcm = note.rseed().rcm(&rho);
1848        let cm = note.commitment();
1849        let recipient = note.recipient();
1850
1851        NoteSlotWitness {
1852            g_d: Value::known(recipient.g_d()),
1853            pk_d: Value::known(
1854                NonIdentityPallasPoint::from_bytes(&recipient.pk_d().to_bytes()).unwrap(),
1855            ),
1856            v: Value::known(note.value()),
1857            rho: Value::known(rho.into_inner()),
1858            psi: Value::known(psi),
1859            rcm: Value::known(rcm),
1860            cm: Value::known(cm),
1861            path: Value::known(*auth_path),
1862            pos: Value::known(pos),
1863            imt_nf_bounds: Value::known(imt.nf_bounds),
1864            imt_leaf_pos: Value::known(imt.leaf_pos),
1865            imt_path: Value::known(imt.path),
1866            is_internal: Value::known(is_internal),
1867        }
1868    }
1869
1870    /// Return value from `make_test_data` bundling all test artefacts.
1871    struct TestData {
1872        circuit: Circuit,
1873        instance: Instance,
1874    }
1875
1876    /// Build a valid merged circuit with 1 real note + 4 padded notes.
1877    fn make_test_data() -> TestData {
1878        let mut rng = OsRng;
1879
1880        let sk = SpendingKey::random(&mut rng);
1881        let fvk: FullViewingKey = (&sk).into();
1882        let output_recipient = fvk.address_at(1u32, Scope::External);
1883
1884        // Key material.
1885        let nk_val = fvk.nk().inner();
1886        let ak: SpendValidatingKey = fvk.clone().into();
1887
1888        let vote_round_id = pallas::Base::random(&mut rng);
1889        let dom = derive_nullifier_domain(vote_round_id);
1890        let van_comm_rand = pallas::Base::random(&mut rng);
1891
1892        // Shared IMT provider (consistent root for all notes).
1893        let imt_provider = SpacedLeafImtProvider::new();
1894        let nf_imt_root = imt_provider.root();
1895
1896        // Real note (slot 0) with value = 13,000,000.
1897        let recipient = fvk.address_at(0u32, Scope::External);
1898        let note_value = NoteValue::from_raw(13_000_000);
1899        let (_, _, dummy_parent) = Note::dummy(&mut rng, None);
1900        let real_note = Note::new(
1901            recipient,
1902            note_value,
1903            Rho::from_nf_old(dummy_parent.nullifier(&fvk)),
1904            &mut rng,
1905        );
1906
1907        // Build Merkle tree with real note at position 0.
1908        let cmx_real_e = ExtractedNoteCommitment::from(real_note.commitment());
1909        let cmx_real = cmx_real_e.inner();
1910        let empty_leaf = MerkleHashOrchard::empty_leaf();
1911        let leaves = [
1912            MerkleHashOrchard::from_cmx(&cmx_real_e),
1913            empty_leaf,
1914            empty_leaf,
1915            empty_leaf,
1916        ];
1917        let l1_0 = MerkleHashOrchard::combine(Level::from(0), &leaves[0], &leaves[1]);
1918        let l1_1 = MerkleHashOrchard::combine(Level::from(0), &leaves[2], &leaves[3]);
1919        let l2_0 = MerkleHashOrchard::combine(Level::from(1), &l1_0, &l1_1);
1920
1921        let mut current = l2_0;
1922        for level in 2..MERKLE_DEPTH_ORCHARD {
1923            let sibling = MerkleHashOrchard::empty_root(Level::from(level as u8));
1924            current = MerkleHashOrchard::combine(Level::from(level as u8), &current, &sibling);
1925        }
1926        let nc_root = current.inner();
1927
1928        let mut auth_path_0 = [MerkleHashOrchard::empty_leaf(); MERKLE_DEPTH_ORCHARD];
1929        auth_path_0[0] = leaves[1];
1930        auth_path_0[1] = l1_1;
1931        for level in 2..MERKLE_DEPTH_ORCHARD {
1932            auth_path_0[level] = MerkleHashOrchard::empty_root(Level::from(level as u8));
1933        }
1934        // IMT proof for real note (from shared provider).
1935        let real_nf = real_note.nullifier(&fvk);
1936        let imt_0 = imt_provider.non_membership_proof(real_nf.inner()).unwrap();
1937        let gov_null_0 = gov_null_hash(nk_val, dom, real_nf.inner());
1938
1939        let slot_0 = make_note_slot(&real_note, &auth_path_0, 0u32, &imt_0, false);
1940
1941        // Padded notes (slots 1-4): zero-value notes with addresses from the real ivk.
1942        let mut note_slots = vec![slot_0];
1943        let mut cmx_values = vec![cmx_real];
1944        let mut gov_nulls = vec![gov_null_0];
1945
1946        let dummy_auth_path = [MerkleHashOrchard::empty_leaf(); MERKLE_DEPTH_ORCHARD];
1947
1948        for i in 1..5u32 {
1949            // Use fvk.address_at() so pk_d = [ivk] * g_d with the REAL ivk.
1950            let pad_addr = fvk.address_at(100 + i, Scope::External);
1951            let (_, _, dummy) = Note::dummy(&mut rng, None);
1952            let pad_note = Note::new(
1953                pad_addr,
1954                NoteValue::ZERO,
1955                Rho::from_nf_old(dummy.nullifier(&fvk)),
1956                &mut rng,
1957            );
1958
1959            let pad_cmx = ExtractedNoteCommitment::from(pad_note.commitment()).inner();
1960            let pad_nf = pad_note.nullifier(&fvk);
1961            let pad_imt = imt_provider.non_membership_proof(pad_nf.inner()).unwrap();
1962            let pad_gov_null = gov_null_hash(nk_val, dom, pad_nf.inner());
1963
1964            note_slots.push(make_note_slot(
1965                &pad_note,
1966                &dummy_auth_path,
1967                0u32,
1968                &pad_imt,
1969                false,
1970            ));
1971            cmx_values.push(pad_cmx);
1972            gov_nulls.push(pad_gov_null);
1973        }
1974
1975        let notes: [NoteSlotWitness; 5] = note_slots.try_into().unwrap();
1976
1977        // Values: real note = 13M, padded = 0.
1978        // Ballot scaling: 13,000,000 / 12,500,000 = 1 ballot, remainder = 500,000.
1979        let v_total_u64: u64 = 13_000_000;
1980        let num_ballots_u64 = v_total_u64 / BALLOT_DIVISOR;
1981        let remainder_u64 = v_total_u64 % BALLOT_DIVISOR;
1982        let num_ballots_field = pallas::Base::from(num_ballots_u64);
1983
1984        // Compute van_comm.
1985        let g_d_new_x = *output_recipient
1986            .g_d()
1987            .to_affine()
1988            .coordinates()
1989            .unwrap()
1990            .x();
1991        let pk_d_new_x = *output_recipient
1992            .pk_d()
1993            .inner()
1994            .to_affine()
1995            .coordinates()
1996            .unwrap()
1997            .x();
1998        let van_comm = van_commitment_hash(
1999            g_d_new_x,
2000            pk_d_new_x,
2001            num_ballots_field,
2002            vote_round_id,
2003            van_comm_rand,
2004        );
2005
2006        // Compute rho.
2007        let rho = rho_binding_hash(
2008            cmx_values[0],
2009            cmx_values[1],
2010            cmx_values[2],
2011            cmx_values[3],
2012            cmx_values[4],
2013            van_comm,
2014            vote_round_id,
2015        );
2016
2017        // Create signed note with this rho (value = 1 per ZIP §Dummy Signed Note).
2018        let sender_address = fvk.address_at(0u32, Scope::External);
2019        let signed_note = Note::new(
2020            sender_address,
2021            NoteValue::from_raw(1),
2022            Rho::from_nf_old(Nullifier::from_inner(rho)),
2023            &mut rng,
2024        );
2025        let nf_signed = signed_note.nullifier(&fvk);
2026
2027        // Create output note with rho = nf_signed.
2028        let output_note = Note::new(
2029            output_recipient,
2030            NoteValue::ZERO,
2031            Rho::from_nf_old(nf_signed),
2032            &mut rng,
2033        );
2034        let cmx_new = ExtractedNoteCommitment::from(output_note.commitment()).inner();
2035
2036        let alpha = pallas::Scalar::random(&mut rng);
2037        let rk = ak.randomize(&alpha);
2038
2039        let circuit = Circuit::from_note_unchecked(&fvk, &signed_note, alpha)
2040            .with_output_note(&output_note)
2041            .with_notes(notes)
2042            .with_van_comm_rand(van_comm_rand)
2043            .with_ballot_scaling(
2044                pallas::Base::from(num_ballots_u64),
2045                pallas::Base::from(remainder_u64),
2046            );
2047
2048        let instance = Instance::from_parts(
2049            nf_signed,
2050            rk,
2051            cmx_new,
2052            van_comm,
2053            vote_round_id,
2054            nc_root,
2055            nf_imt_root,
2056            [
2057                gov_nulls[0],
2058                gov_nulls[1],
2059                gov_nulls[2],
2060                gov_nulls[3],
2061                gov_nulls[4],
2062            ],
2063            dom,
2064        );
2065
2066        TestData { circuit, instance }
2067    }
2068
2069    #[test]
2070    fn happy_path() {
2071        let t = make_test_data();
2072        let pi = t.instance.to_halo2_instance();
2073
2074        let prover = MockProver::run(K, &t.circuit, vec![pi]).unwrap();
2075        assert_eq!(prover.verify(), Ok(()));
2076    }
2077
2078    #[test]
2079    fn wrong_nf_fails() {
2080        let t = make_test_data();
2081        let mut instance = t.instance.clone();
2082        instance.nf_signed = Nullifier::from_inner(pallas::Base::random(&mut OsRng));
2083
2084        let pi = instance.to_halo2_instance();
2085        let prover = MockProver::run(K, &t.circuit, vec![pi]).unwrap();
2086        assert!(prover.verify().is_err());
2087    }
2088
2089    #[test]
2090    fn wrong_rk_fails() {
2091        let mut rng = OsRng;
2092        let t = make_test_data();
2093
2094        let sk2 = SpendingKey::random(&mut rng);
2095        let fvk2: FullViewingKey = (&sk2).into();
2096        let ak2: SpendValidatingKey = fvk2.into();
2097        let wrong_rk = ak2.randomize(&pallas::Scalar::random(&mut rng));
2098
2099        let mut instance = t.instance.clone();
2100        instance.rk = wrong_rk;
2101
2102        let pi = instance.to_halo2_instance();
2103        let prover = MockProver::run(K, &t.circuit, vec![pi]).unwrap();
2104        assert!(prover.verify().is_err());
2105    }
2106
2107    #[test]
2108    fn wrong_gov_null_fails() {
2109        let t = make_test_data();
2110        let mut instance = t.instance.clone();
2111        instance.gov_null[0] = pallas::Base::random(&mut OsRng);
2112
2113        let pi = instance.to_halo2_instance();
2114        let prover = MockProver::run(K, &t.circuit, vec![pi]).unwrap();
2115        assert!(prover.verify().is_err());
2116    }
2117
2118    #[test]
2119    fn wrong_nc_root_fails() {
2120        let t = make_test_data();
2121        let mut instance = t.instance.clone();
2122        instance.nc_root = pallas::Base::random(&mut OsRng);
2123
2124        let pi = instance.to_halo2_instance();
2125        let prover = MockProver::run(K, &t.circuit, vec![pi]).unwrap();
2126        assert!(prover.verify().is_err());
2127    }
2128
2129    #[test]
2130    fn wrong_imt_root_fails() {
2131        let t = make_test_data();
2132        let mut instance = t.instance.clone();
2133        instance.nf_imt_root = pallas::Base::random(&mut OsRng);
2134
2135        let pi = instance.to_halo2_instance();
2136        let prover = MockProver::run(K, &t.circuit, vec![pi]).unwrap();
2137        assert!(prover.verify().is_err());
2138    }
2139
2140    #[test]
2141    fn wrong_van_comm_fails() {
2142        let t = make_test_data();
2143        let mut instance = t.instance.clone();
2144        instance.van_comm = pallas::Base::random(&mut OsRng);
2145
2146        let pi = instance.to_halo2_instance();
2147        let prover = MockProver::run(K, &t.circuit, vec![pi]).unwrap();
2148        assert!(prover.verify().is_err());
2149    }
2150
2151    #[test]
2152    fn wrong_vote_round_id_fails() {
2153        let t = make_test_data();
2154        let mut instance = t.instance.clone();
2155        instance.vote_round_id = pallas::Base::random(&mut OsRng);
2156
2157        let pi = instance.to_halo2_instance();
2158        let prover = MockProver::run(K, &t.circuit, vec![pi]).unwrap();
2159        assert!(prover.verify().is_err());
2160    }
2161
2162    #[test]
2163    fn instance_to_halo2_roundtrip() {
2164        let t = make_test_data();
2165        let pi = t.instance.to_halo2_instance();
2166        assert_eq!(pi.len(), 14, "Expected exactly 14 public inputs");
2167        assert_eq!(pi[NF_SIGNED], t.instance.nf_signed.inner());
2168        assert_eq!(pi[CMX_NEW], t.instance.cmx_new);
2169        assert_eq!(pi[VAN_COMM], t.instance.van_comm);
2170        assert_eq!(pi[NC_ROOT], t.instance.nc_root);
2171        assert_eq!(pi[NF_IMT_ROOT], t.instance.nf_imt_root);
2172        assert_eq!(pi[GOV_NULL_1], t.instance.gov_null[0]);
2173        assert_eq!(pi[DOM], t.instance.dom);
2174    }
2175
2176    #[test]
2177    fn default_circuit_shape() {
2178        let t = make_test_data();
2179        let empty = plonk::Circuit::without_witnesses(&t.circuit);
2180        let params = halo2_proofs::poly::commitment::Params::<vesta::Affine>::new(K);
2181        let vk = halo2_proofs::plonk::keygen_vk(&params, &empty);
2182        assert!(
2183            vk.is_ok(),
2184            "keygen_vk must succeed on without_witnesses circuit"
2185        );
2186    }
2187
2188    // Condition 10: v > 0 with a non-existent note claiming non-zero value.
2189    // The Merkle path check gates on v: v * (root - anchor) = 0.
2190    // When v > 0, root must equal nc_root — a fake auth path fails.
2191    #[test]
2192    fn fake_real_note_nonzero_value_fails() {
2193        let mut rng = OsRng;
2194        let t = make_test_data();
2195        let mut circuit = t.circuit;
2196        let pi = t.instance.to_halo2_instance();
2197
2198        // Build a note with v > 0 using a fresh key; it is NOT in the commitment
2199        // tree that make_test_data() built (nc_root only covers slot 0's real note).
2200        let sk2 = SpendingKey::random(&mut rng);
2201        let fvk2: FullViewingKey = (&sk2).into();
2202        let addr2 = fvk2.address_at(0u32, Scope::External);
2203        let (_, _, dummy_parent) = Note::dummy(&mut rng, None);
2204        let fake_note = Note::new(
2205            addr2,
2206            NoteValue::from_raw(100), // v > 0: not a zero-value padded note
2207            Rho::from_nf_old(dummy_parent.nullifier(&fvk2)),
2208            &mut rng,
2209        );
2210
2211        let imt_provider = SpacedLeafImtProvider::new();
2212        let fake_nf = fake_note.nullifier(&fvk2);
2213        let fake_imt = imt_provider.non_membership_proof(fake_nf.inner()).unwrap();
2214
2215        // All empty siblings — this auth path does not open to nc_root.
2216        let dummy_auth_path = [MerkleHashOrchard::empty_leaf(); MERKLE_DEPTH_ORCHARD];
2217        // v > 0 activates condition 10: v * (root - nc_root) = 0.
2218        let fake_slot = make_note_slot(&fake_note, &dummy_auth_path, 0u32, &fake_imt, false);
2219
2220        // Replace slot 1 (was padded, v=0) with the fake claim.
2221        circuit.notes[1] = fake_slot;
2222
2223        let prover = MockProver::run(K, &circuit, vec![pi]).unwrap();
2224        // Condition 10: the dummy auth path produces a computed root ≠ nc_root,
2225        // and v > 0, so the Merkle path check rejects the non-existent note.
2226        assert!(prover.verify().is_err());
2227    }
2228
2229    // Condition 11 copy-constraint: confirms that ivk from condition 5 (the signed
2230    // note's key) is enforced in ALL per-note pk_d ownership checks via copy constraint.
2231    // If the per-note addresses use a different key, condition 11 fails even though
2232    // condition 10 (Merkle path) is skipped (v=0 dummy note).
2233    #[test]
2234    fn different_ivk_per_note_fails() {
2235        let mut rng = OsRng;
2236        let t = make_test_data();
2237        let mut circuit = t.circuit;
2238        let pi = t.instance.to_halo2_instance();
2239
2240        // Build a note from a different key (fvk2). The circuit derives ivk1 in
2241        // condition 5 (from fvk1, the signed note's key) and the copy constraint
2242        // propagates ivk1 into every condition 11 per-note ownership check.
2243        // For this foreign slot: [ivk1] * g_d_fvk2 ≠ pk_d_fvk2, so condition 11 fails.
2244        let sk2 = SpendingKey::random(&mut rng);
2245        let fvk2: FullViewingKey = (&sk2).into();
2246        let addr2 = fvk2.address_at(100u32, Scope::External);
2247        let (_, _, dummy_parent) = Note::dummy(&mut rng, None);
2248        let foreign_note = Note::new(
2249            addr2,
2250            NoteValue::ZERO,
2251            Rho::from_nf_old(dummy_parent.nullifier(&fvk2)),
2252            &mut rng,
2253        );
2254
2255        let imt_provider = SpacedLeafImtProvider::new();
2256        let foreign_nf = foreign_note.nullifier(&fvk2);
2257        let foreign_imt = imt_provider
2258            .non_membership_proof(foreign_nf.inner())
2259            .unwrap();
2260
2261        let dummy_auth_path = [MerkleHashOrchard::empty_leaf(); MERKLE_DEPTH_ORCHARD];
2262        // v=0: condition 10 (Merkle root check) is skipped.
2263        // Condition 11 still applies to all slots and cannot be bypassed.
2264        let foreign_slot =
2265            make_note_slot(&foreign_note, &dummy_auth_path, 0u32, &foreign_imt, false);
2266
2267        circuit.notes[1] = foreign_slot;
2268
2269        let prover = MockProver::run(K, &circuit, vec![pi]).unwrap();
2270        // Condition 11: the copy constraint forces ivk from condition 5 (fvk1's ivk)
2271        // into this check; [ivk1] * g_d_fvk2 ≠ pk_d_fvk2 so the constraint fails,
2272        // confirming substitution of a foreign ivk in condition 11 is impossible.
2273        assert!(prover.verify().is_err());
2274    }
2275
2276    // ----------------------------------------------------------------
2277    // Cost breakdown — per-region row counts via a custom Assignment
2278    // ----------------------------------------------------------------
2279
2280    use std::collections::BTreeMap;
2281
2282    use halo2_proofs::plonk::{Any, Assigned, Assignment, Column, Error, Fixed, FloorPlanner};
2283
2284    struct RegionInfo {
2285        name: String,
2286        min_row: Option<usize>,
2287        max_row: Option<usize>,
2288    }
2289
2290    impl RegionInfo {
2291        fn track_row(&mut self, row: usize) {
2292            self.min_row = Some(self.min_row.map_or(row, |m| m.min(row)));
2293            self.max_row = Some(self.max_row.map_or(row, |m| m.max(row)));
2294        }
2295
2296        fn row_count(&self) -> usize {
2297            match (self.min_row, self.max_row) {
2298                (Some(lo), Some(hi)) => hi - lo + 1,
2299                _ => 0,
2300            }
2301        }
2302    }
2303
2304    struct RegionTracker {
2305        regions: Vec<RegionInfo>,
2306        current_region: Option<usize>,
2307        total_rows: usize,
2308        namespace_stack: Vec<String>,
2309    }
2310
2311    impl RegionTracker {
2312        fn new() -> Self {
2313            Self {
2314                regions: Vec::new(),
2315                current_region: None,
2316                total_rows: 0,
2317                namespace_stack: Vec::new(),
2318            }
2319        }
2320
2321        fn current_prefix(&self) -> String {
2322            if self.namespace_stack.is_empty() {
2323                String::new()
2324            } else {
2325                format!("{}/", self.namespace_stack.join("/"))
2326            }
2327        }
2328    }
2329
2330    impl Assignment<pallas::Base> for RegionTracker {
2331        fn enter_region<NR, N>(&mut self, name_fn: N)
2332        where
2333            NR: Into<String>,
2334            N: FnOnce() -> NR,
2335        {
2336            let idx = self.regions.len();
2337            let raw_name: String = name_fn().into();
2338            let prefixed = format!("{}{}", self.current_prefix(), raw_name);
2339            self.regions.push(RegionInfo {
2340                name: prefixed,
2341                min_row: None,
2342                max_row: None,
2343            });
2344            self.current_region = Some(idx);
2345        }
2346
2347        fn exit_region(&mut self) {
2348            self.current_region = None;
2349        }
2350
2351        fn enable_selector<A, AR>(
2352            &mut self,
2353            _: A,
2354            _selector: &Selector,
2355            row: usize,
2356        ) -> Result<(), Error>
2357        where
2358            A: FnOnce() -> AR,
2359            AR: Into<String>,
2360        {
2361            if let Some(idx) = self.current_region {
2362                self.regions[idx].track_row(row);
2363            }
2364            if row + 1 > self.total_rows {
2365                self.total_rows = row + 1;
2366            }
2367            Ok(())
2368        }
2369
2370        fn query_instance(
2371            &self,
2372            _column: Column<InstanceColumn>,
2373            _row: usize,
2374        ) -> Result<Value<pallas::Base>, Error> {
2375            Ok(Value::unknown())
2376        }
2377
2378        fn assign_advice<V, VR, A, AR>(
2379            &mut self,
2380            _: A,
2381            _column: Column<Advice>,
2382            row: usize,
2383            _to: V,
2384        ) -> Result<(), Error>
2385        where
2386            V: FnOnce() -> Value<VR>,
2387            VR: Into<Assigned<pallas::Base>>,
2388            A: FnOnce() -> AR,
2389            AR: Into<String>,
2390        {
2391            if let Some(idx) = self.current_region {
2392                self.regions[idx].track_row(row);
2393            }
2394            if row + 1 > self.total_rows {
2395                self.total_rows = row + 1;
2396            }
2397            Ok(())
2398        }
2399
2400        fn assign_fixed<V, VR, A, AR>(
2401            &mut self,
2402            _: A,
2403            _column: Column<Fixed>,
2404            row: usize,
2405            _to: V,
2406        ) -> Result<(), Error>
2407        where
2408            V: FnOnce() -> Value<VR>,
2409            VR: Into<Assigned<pallas::Base>>,
2410            A: FnOnce() -> AR,
2411            AR: Into<String>,
2412        {
2413            if let Some(idx) = self.current_region {
2414                self.regions[idx].track_row(row);
2415            }
2416            if row + 1 > self.total_rows {
2417                self.total_rows = row + 1;
2418            }
2419            Ok(())
2420        }
2421
2422        fn copy(
2423            &mut self,
2424            _left_column: Column<Any>,
2425            _left_row: usize,
2426            _right_column: Column<Any>,
2427            _right_row: usize,
2428        ) -> Result<(), Error> {
2429            Ok(())
2430        }
2431
2432        fn fill_from_row(
2433            &mut self,
2434            _column: Column<Fixed>,
2435            _row: usize,
2436            _to: Value<Assigned<pallas::Base>>,
2437        ) -> Result<(), Error> {
2438            Ok(())
2439        }
2440
2441        fn push_namespace<NR, N>(&mut self, name_fn: N)
2442        where
2443            NR: Into<String>,
2444            N: FnOnce() -> NR,
2445        {
2446            self.namespace_stack.push(name_fn().into());
2447        }
2448
2449        fn pop_namespace(&mut self, _: Option<String>) {
2450            self.namespace_stack.pop();
2451        }
2452    }
2453
2454    #[test]
2455    fn cost_breakdown() {
2456        // 1. Configure constraint system
2457        let mut cs = plonk::ConstraintSystem::default();
2458        let config = <Circuit as plonk::Circuit<pallas::Base>>::configure(&mut cs);
2459
2460        // 2. Run floor planner with our tracker.
2461        //    Provide a fixed column for constants — the configure call above registered
2462        //    one via enable_constant, but cs.constants is pub(crate). We create a fresh
2463        //    fixed column; it won't match the real one but the V1 planner only needs
2464        //    *some* column to place constants into. Row counts are unaffected.
2465        let constants_col = cs.fixed_column();
2466        let circuit = Circuit::default();
2467        let mut tracker = RegionTracker::new();
2468        floor_planner::V1::synthesize(&mut tracker, &circuit, config, vec![constants_col]).unwrap();
2469
2470        // 3. Collect and sort regions by row count (descending)
2471        let mut regions: Vec<_> = tracker
2472            .regions
2473            .iter()
2474            .filter(|r| r.row_count() > 0)
2475            .collect();
2476        regions.sort_by(|a, b| b.row_count().cmp(&a.row_count()));
2477
2478        std::println!(
2479            "\n=== Delegation Circuit Cost Breakdown (K={}, {} total rows) ===",
2480            K,
2481            1u64 << K
2482        );
2483        std::println!("Total rows used: {}\n", tracker.total_rows);
2484
2485        std::println!("Per-region (sorted by cost):");
2486        for r in &regions {
2487            std::println!(
2488                "  {:60} {:>6} rows  (rows {}-{})",
2489                r.name,
2490                r.row_count(),
2491                r.min_row.unwrap(),
2492                r.max_row.unwrap()
2493            );
2494        }
2495
2496        // 4. Aggregate by top-level condition
2497        std::println!("\nAggregated by top-level condition:");
2498        let mut aggregated: BTreeMap<String, (usize, usize)> = BTreeMap::new();
2499        for r in &tracker.regions {
2500            if r.row_count() == 0 {
2501                continue;
2502            }
2503            let key = if r.name.starts_with("note ")
2504                && r.name
2505                    .as_bytes()
2506                    .get(5)
2507                    .map_or(false, |b| b.is_ascii_digit())
2508            {
2509                if let Some(slash) = r.name.find('/') {
2510                    let rest = &r.name[slash + 1..];
2511                    let top = rest.split('/').next().unwrap_or(rest);
2512                    let top = if top.starts_with("MerkleCRH(") {
2513                        "Merkle path (Sinsemilla)"
2514                    } else if top.starts_with("Poseidon(left, right) level") {
2515                        "IMT Poseidon path"
2516                    } else if top.starts_with("imt swap level") {
2517                        "IMT swap"
2518                    } else {
2519                        top
2520                    };
2521                    format!("Per-note: {}", top)
2522                } else {
2523                    r.name.clone()
2524                }
2525            } else {
2526                let top = r.name.split('/').next().unwrap_or(&r.name);
2527                top.to_string()
2528            };
2529            let entry = aggregated.entry(key).or_insert((0, 0));
2530            entry.0 += r.row_count();
2531            entry.1 += 1;
2532        }
2533        let mut agg_sorted: Vec<_> = aggregated.into_iter().collect();
2534        agg_sorted.sort_by(|a, b| b.1 .0.cmp(&a.1 .0));
2535        for (name, (total, count)) in &agg_sorted {
2536            if *count > 1 {
2537                std::println!(
2538                    "  {:60} {:>6} rows  ({} x{})",
2539                    name,
2540                    total,
2541                    total / count,
2542                    count
2543                );
2544            } else {
2545                std::println!("  {:60} {:>6} rows", name, total);
2546            }
2547        }
2548        std::println!();
2549    }
2550
2551    /// Measures actual rows used by the delegation circuit via `CircuitCost::measure`.
2552    ///
2553    /// `CircuitCost` runs the floor planner against the circuit and tracks the
2554    /// highest row offset assigned in any column, giving the real "rows consumed"
2555    /// number rather than the theoretical 2^K capacity.
2556    ///
2557    /// Run with:
2558    ///   cargo test row_budget -- --nocapture --ignored
2559    #[test]
2560    #[ignore]
2561    fn row_budget() {
2562        use halo2_proofs::dev::CircuitCost;
2563        use pasta_curves::vesta;
2564        use std::println;
2565
2566        let t = make_test_data();
2567
2568        let cost = CircuitCost::<vesta::Point, _>::measure(K, &t.circuit);
2569        let debug = format!("{cost:?}");
2570
2571        let extract = |field: &str| -> usize {
2572            let prefix = format!("{field}: ");
2573            debug
2574                .split(&prefix)
2575                .nth(1)
2576                .and_then(|s| s.split([',', ' ', '}']).next())
2577                .and_then(|n| n.parse().ok())
2578                .unwrap_or(0)
2579        };
2580
2581        let max_rows = extract("max_rows");
2582        let max_advice_rows = extract("max_advice_rows");
2583        let max_fixed_rows = extract("max_fixed_rows");
2584        let total_available = 1usize << K;
2585
2586        println!("=== delegation circuit row budget (K={K}) ===");
2587        println!("  max_rows (floor-planner high-water mark): {max_rows}");
2588        println!("  max_advice_rows:                          {max_advice_rows}");
2589        println!("  max_fixed_rows:                           {max_fixed_rows}");
2590        println!("  2^K  (total available rows):              {total_available}");
2591        println!(
2592            "  headroom:                                 {}",
2593            total_available.saturating_sub(max_rows)
2594        );
2595        println!(
2596            "  utilisation:                              {:.1}%",
2597            100.0 * max_rows as f64 / total_available as f64
2598        );
2599        println!();
2600        println!("  Full debug: {debug}");
2601
2602        // Witness-independence check: Circuit::default() (all unknowns)
2603        // must produce exactly the same layout as the filled circuit.
2604        let cost_default = CircuitCost::<vesta::Point, _>::measure(K, &Circuit::default());
2605        let debug_default = format!("{cost_default:?}");
2606        let max_rows_default = debug_default
2607            .split("max_rows: ")
2608            .nth(1)
2609            .and_then(|s| s.split([',', ' ', '}']).next())
2610            .and_then(|n| n.parse::<usize>().ok())
2611            .unwrap_or(0);
2612        if max_rows_default == max_rows {
2613            println!(
2614                "  Witness-independence: PASS \
2615                (Circuit::default() max_rows={max_rows_default} == filled max_rows={max_rows})"
2616            );
2617        } else {
2618            println!(
2619                "  Witness-independence: FAIL \
2620                (Circuit::default() max_rows={max_rows_default} != filled max_rows={max_rows}) \
2621                — row count depends on witness values!"
2622            );
2623        }
2624
2625        println!("  MERKLE_DEPTH_ORCHARD (circuit constant): {MERKLE_DEPTH_ORCHARD}");
2626        println!("  IMT_DEPTH (circuit constant):             {IMT_DEPTH}");
2627
2628        // Minimum-K probe: find the smallest K at which MockProver passes.
2629        for probe_k in 11u32..=K {
2630            let t = make_test_data();
2631            match MockProver::run(probe_k, &t.circuit, vec![t.instance.to_halo2_instance()]) {
2632                Err(_) => {
2633                    println!("  K={probe_k}: not enough rows (synthesizer rejected)");
2634                    continue;
2635                }
2636                Ok(p) => match p.verify() {
2637                    Ok(()) => {
2638                        println!("  Minimum viable K: {probe_k} (2^{probe_k} = {} rows, {:.1}% headroom)",
2639                            1usize << probe_k,
2640                            100.0 * (1.0 - max_rows as f64 / (1usize << probe_k) as f64));
2641                        break;
2642                    }
2643                    Err(_) => println!("  K={probe_k}: too small"),
2644                },
2645            }
2646        }
2647    }
2648}