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