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