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