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