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