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