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