Skip to content

Commit f91cc75

Browse files
committed
Align delegation circuit with ZIP spec (dom, value=1, remove is_real)
Three changes to align with zcash/zips#1199: 1. Governance nullifier → alternate nullifier with explicit dom public input (14th instance field). dom = Poseidon("governance authorization", vote_round_id) derived out-of-circuit. In-circuit: Poseidon(nk, dom, real_nf) with ConstantLength<3>. 2. Dummy signed note value 0 → 1 to match PCZT construction. 3. Replace is_note_real with v * (root - anchor) = 0 (Orchard standard dummy note mechanism). Remove condition 15.
1 parent 05503e2 commit f91cc75

6 files changed

Lines changed: 156 additions & 167 deletions

File tree

voting-circuits/src/delegation/README.md

Lines changed: 15 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,20 +2,21 @@
22

33
A single circuit proving all 15 conditions of the delegation ZKP at K=14 (16,384 rows). The circuit handles the keystone note (conditions 1–8) and five per-note slots (conditions 9–15 ×5) in one proof.
44

5-
**Public inputs:** 13 field elements.
5+
**Public inputs:** 14 field elements.
66
**Per-note slots:** 5 (unused slots are padded with zero-value notes).
77

88
## Inputs
99

10-
- Public (13 field elements)
10+
- Public (14 field elements)
1111
* **nf_signed** (offset 0): the derived nullifier of the keystone note.
1212
* **rk** (offsets 1–2): the randomized public key for spend authorization (x, y coordinates).
1313
* **cmx_new** (offset 3): the extracted note commitment (`ExtractP(cm_new)`) of the output note.
1414
* **van_comm** (offset 4): the governance commitment — a Pallas base field element identifying the governance context.
1515
* **vote_round_id** (offset 5): the vote round identifier — prevents cross-round replay.
1616
* **nc_root** (offset 6): the note commitment tree root (shared anchor for Merkle path verification).
1717
* **nf_imt_root** (offset 7): the nullifier Indexed Merkle Tree root (for non-membership proofs).
18-
* **gov_null_1..5** (offsets 8–12): per-note governance nullifiers, one per note slot.
18+
* **gov_null_1..5** (offsets 8–12): per-note alternate nullifiers, one per note slot.
19+
* **dom** (offset 13): the nullifier domain — derived out-of-circuit as Poseidon("governance authorization", vote_round_id).
1920

2021
- Private (keystone note)
2122
* **rho_signed** ("rho"): the nullifier of the note that was spent to create the signed note.
@@ -46,7 +47,6 @@ A single circuit proving all 15 conditions of the delegation ZKP at K=14 (16,384
4647
* **cm**: note commitment, witnessed as an ECC point.
4748
* **path**: Sinsemilla-based Merkle authentication path (32 siblings).
4849
* **pos**: leaf position in the note commitment tree.
49-
* **is_note_real**: boolean flag — 1 for real notes, 0 for padded notes.
5050
* **is_internal**: boolean flag — 1 for internal (change) scope notes, 0 for external scope notes.
5151
* **imt_low**: the interval start (low bound of the bracketing leaf).
5252
* **imt_width**: the interval width (`high - low`, pre-computed during tree construction).
@@ -267,16 +267,16 @@ The `v` cell is a `NoteValue` inside NoteCommit. A separate `v_base` cell (as `p
267267

268268
## 10. Merkle Path Validity (x5)
269269

270-
Purpose: prove that the note's commitment exists in the note commitment tree, gated by `is_note_real`.
270+
Purpose: prove that the note's commitment exists in the note commitment tree. Uses Orchard's standard dummy note mechanism: the check is gated by value (ZIP §Note Padding).
271271

272272
```
273273
root = MerklePath(cmx, pos, path)
274-
is_note_real * (root - nc_root) = 0
274+
v * (root - nc_root) = 0
275275
```
276276

277-
The `GadgetMerklePath` gadget computes the Merkle root from the leaf (`cmx`) and the 32-level authentication path using Sinsemilla hashing. The `q_per_note` custom gate then enforces that either the computed root equals the public `nc_root` anchor or `is_note_real = 0` (padded note — root mismatch is allowed).
277+
The `GadgetMerklePath` gadget computes the Merkle root from the leaf (`cmx`) and the 32-level authentication path using Sinsemilla hashing. The `q_per_note` custom gate then enforces that either the computed root equals the public `nc_root` anchor or `v = 0` (dummy note — root mismatch is allowed).
278278

279-
For padded notes, the path can be all-zeros; the Merkle computation still runs but the root-check gate is gated off.
279+
For dummy notes (v=0), the path can be all-zeros; the Merkle computation still runs but the root-check gate is gated off.
280280

281281
**Constructions:** `MerkleChip` (configs 1+2), `SinsemillaChip` (configs 1+2 via MerkleChip), `q_per_note`.
282282

@@ -328,7 +328,7 @@ Purpose: prove the note's nullifier has NOT been spent, using a Poseidon-based I
328328
- `left + right = current + sibling`
329329
- `bool_check(pos_bit)`
330330

331-
3. **Root check**: The `q_per_note` gate constrains `imt_root = nf_imt_root` (the public input). Unlike the Merkle root check in condition 10, this is **not gated** by `is_note_real` — padded notes must also provide a valid IMT non-membership proof.
331+
3. **Root check**: The `q_per_note` gate constrains `imt_root = nf_imt_root` (the public input). This is unconditional — dummy notes must also provide a valid IMT non-membership proof.
332332

333333
4. **Interval check** (`q_interval` gate): Proves `low <= real_nf <= low + width` using 2 constraints:
334334
- `x = real_nf - low` (offset into interval)
@@ -342,41 +342,23 @@ Purpose: prove the note's nullifier has NOT been spent, using a Poseidon-based I
342342

343343
**Constructions:** `PoseidonChip`, `LookupRangeCheckConfig`, `q_imt_swap`, `q_interval`, `q_per_note`.
344344

345-
## 14. Governance Nullifier Publication (x5)
345+
## 14. Alternate Nullifier Integrity (x5)
346346

347-
Purpose: derive a domain-separated governance nullifier that is published as a public input. This prevents double-voting without revealing the note's true Orchard nullifier.
347+
Purpose: derive an alternate nullifier (ZIP §Alternate Nullifier Derivation) that is published as a public input. This prevents double-delegation without revealing the note's true Orchard nullifier.
348348

349349
```
350-
gov_null = Poseidon(nk, domain_tag, vote_round_id, real_nf)
350+
nf_dom = Poseidon(nk, dom, real_nf)
351351
```
352352

353-
Single Poseidon hash (`ConstantLength<4>`, 2 permutations at rate 2):
353+
Single Poseidon hash (`ConstantLength<3>`, 2 permutations at rate 2):
354354
- **nk** — the nullifier deriving key, making the result unforgeable.
355-
- **domain_tag**`"governance authorization"` encoded as a little-endian Pallas field element, assigned via `assign_constant` so the value is baked into the verification key. Separates from other nullifier domains.
356-
- **vote_round_id** — binds to the voting round.
355+
- **dom** — the nullifier domain (public input at offset 13), derived out-of-circuit as `Poseidon("governance authorization", vote_round_id)`. Scopes the alternate nullifier to this application instance and voting round.
357356
- **real_nf** — the note's true nullifier from condition 12.
358357

359358
The result is constrained to the public input at offset `GOV_NULL_1..5`.
360359

361360
**Constructions:** `PoseidonChip`.
362361

363-
## 15. Padded-Note Zero-Value Enforcement (x5)
364-
365-
Purpose: ensure padded (unused) note slots contribute zero voting weight.
366-
367-
```
368-
(1 - is_note_real) * v = 0
369-
bool_check(is_note_real)
370-
```
371-
372-
The `q_per_note` custom gate enforces:
373-
1. `is_note_real` is boolean (0 or 1).
374-
2. If `is_note_real = 0`, then `v = 0`. A padded note cannot carry value.
375-
376-
For real notes (`is_note_real = 1`), the constraint is trivially satisfied and `v` can be any value.
377-
378-
**Constructions:** `q_per_note`.
379-
380362
## FAQ
381363

382364
- **"Why is cm_signed witnessed as a Point but ak_P as a NonIdentityPoint?"** — ak_P being identity would be a degenerate key (any signature verifies). cm_signed being identity is cryptographically negligible and caught by the equality constraint with the recomputed commitment.
@@ -387,4 +369,4 @@ For real notes (`is_note_real = 1`), the constraint is trivially satisfied and `
387369

388370
- **"Why two Sinsemilla configs (and two NoteCommitChips)?"** — This mirrors the audited Orchard action circuit, which uses two Sinsemilla configs (one for spend-side NoteCommit, one for output-side NoteCommit) with column assignments `advices[..5]` and `advices[5..]`. Each `SinsemillaChip::configure` call creates its own selectors and gates, and each `NoteCommitChip::configure` creates decomposition/canonicity gates tied to the Sinsemilla config it receives — so two Sinsemilla configs require two NoteCommitChips. We replicate this exact layout so the delegation circuit inherits the audited chip wiring without modification. It may be possible to collapse to a single config (condition 9 already runs 5 NoteCommits on config 1 without conflict), but reusing the known-correct pattern avoids the need for a separate audit of the chip interaction.
389371

390-
- **"Why do padded notes use the real ivk?"**Padded notes must pass condition 11 (`pk_d = [selected_ivk] * g_d`) using the same ivk (or ivk_internal) derived in condition 5. The builder creates padded notes with `fvk.address_at(...)` so their addresses are valid under the real ivk. This is safe because padded notes have `v = 0` (enforced by condition 15) and `is_note_real = 0` (so condition 10 skips the Merkle root check). They contribute nothing to the vote weight but their governance nullifiers are still published (condition 14), which is harmless — the consuming protocol can ignore nullifiers for zero-value notes or treat them as no-ops.
372+
- **"Why do dummy notes use the real ivk?"**Dummy notes must pass condition 11 (`pk_d = [selected_ivk] * g_d`) using the same ivk (or ivk_internal) derived in condition 5. The builder creates dummy notes with `fvk.address_at(...)` so their addresses are valid under the real ivk. This is safe because dummy notes have `v = 0` (so condition 10 skips the Merkle root check via `v * (root - anchor) = 0`). They contribute nothing to the vote weight but their alternate nullifiers are still published (condition 14), which is harmless — the consuming protocol can ignore nullifiers for zero-value notes or treat them as no-ops.

voting-circuits/src/delegation/builder.rs

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ use orchard::{
2121

2222
use super::{
2323
circuit::{self, van_commitment_hash, rho_binding_hash, NoteSlotWitness},
24-
imt::{gov_null_hash, ImtProofData, ImtProvider},
24+
imt::{derive_nullifier_domain, gov_null_hash, ImtProofData, ImtProvider},
2525
};
2626

2727
/// Rho and rseed for a single padded note, captured during Phase 1 (PCZT construction).
@@ -66,7 +66,7 @@ pub struct RealNoteInput {
6666
pub struct DelegationBundle {
6767
/// The merged delegation circuit.
6868
pub circuit: circuit::Circuit,
69-
/// Public inputs (13 field elements).
69+
/// Public inputs (14 field elements).
7070
pub instance: circuit::Instance,
7171
}
7272

@@ -139,6 +139,9 @@ pub fn build_delegation_bundle(
139139
let nk_val = fvk.nk().inner();
140140
let ak: SpendValidatingKey = fvk.clone().into();
141141

142+
// Derive the nullifier domain for this round (ZIP §Nullifier Domains).
143+
let dom = derive_nullifier_domain(vote_round_id);
144+
142145
// Collect per-note data.
143146
let mut note_slots = Vec::with_capacity(5);
144147
let mut cmx_values = Vec::with_capacity(5);
@@ -159,8 +162,8 @@ pub fn build_delegation_bundle(
159162

160163
// Condition 12: real nullifier for IMT non-membership.
161164
let real_nf = note.nullifier(fvk);
162-
// Condition 14: governance nullifier = Poseidon(nk, domain_tag, vote_round_id, real_nf).
163-
let gov_null = gov_null_hash(nk_val, vote_round_id, real_nf.0);
165+
// Condition 14: alternate nullifier = Poseidon(nk, dom, real_nf).
166+
let gov_null = gov_null_hash(nk_val, dom, real_nf.0);
164167

165168
let slot = NoteSlotWitness {
166169
g_d: Value::known(recipient.g_d()),
@@ -174,7 +177,6 @@ pub fn build_delegation_bundle(
174177
cm: Value::known(cm),
175178
path: Value::known(input.merkle_path.auth_path()),
176179
pos: Value::known(input.merkle_path.position()),
177-
is_note_real: Value::known(true),
178180
imt_low: Value::known(input.imt_proof.low),
179181
imt_width: Value::known(input.imt_proof.width),
180182
imt_leaf_pos: Value::known(input.imt_proof.leaf_pos),
@@ -188,10 +190,9 @@ pub fn build_delegation_bundle(
188190
gov_nulls.push(gov_null);
189191
}
190192

191-
// Pad remaining slots to 5 with zero-value dummy notes (§1.3.5).
192-
// Padded notes use random rho/psi/rcm, v=0, and is_note_real=false.
193-
// The circuit still runs all constraints uniformly; condition 10 (Merkle path)
194-
// and condition 15 (v=0) are gated by is_note_real.
193+
// Pad remaining slots to 5 with zero-value dummy notes (ZIP §Note Padding).
194+
// Dummy notes use v=0, which gates condition 10 (Merkle path) via
195+
// v * (root - anchor) = 0. All other conditions run unconditionally.
195196
for i in n_real..5 {
196197
// Use a high diversifier index to avoid collision with real notes.
197198
let pad_addr = fvk.address_at((1000 + i) as u32, Scope::External);
@@ -223,12 +224,12 @@ pub fn build_delegation_bundle(
223224
let cmx = ExtractedNoteCommitment::from(cm.clone()).inner();
224225

225226
let real_nf = pad_note.nullifier(fvk);
226-
let gov_null = gov_null_hash(nk_val, vote_round_id, real_nf.0);
227+
let gov_null = gov_null_hash(nk_val, dom, real_nf.0);
227228

228229
// Get IMT non-membership proof for this padded note's nullifier.
229230
let imt_proof = imt_provider.non_membership_proof(real_nf.0)?;
230231

231-
// Merkle path: zeros (condition 10 is skipped for padded notes).
232+
// Merkle path: dummy (condition 10 is skipped for v=0 notes).
232233
let merkle_path = MerklePath::dummy(&mut *rng);
233234

234235
let slot = NoteSlotWitness {
@@ -243,7 +244,6 @@ pub fn build_delegation_bundle(
243244
cm: Value::known(cm),
244245
path: Value::known(merkle_path.auth_path()),
245246
pos: Value::known(merkle_path.position()),
246-
is_note_real: Value::known(false),
247247
imt_low: Value::known(imt_proof.low),
248248
imt_width: Value::known(imt_proof.width),
249249
imt_leaf_pos: Value::known(imt_proof.leaf_pos),
@@ -300,19 +300,20 @@ pub fn build_delegation_bundle(
300300
vote_round_id,
301301
);
302302

303-
// Construct the keystone (signed) note (§1.3.4).
304-
// This is a zero-value dummy note whose rho is bound to the delegation via condition 3.
303+
// Construct the keystone (signed) note (ZIP §Dummy Signed Note).
304+
// Value is 1 so that hardware wallets (Keystone) render the transaction.
305+
// The rho is bound to the delegation via condition 3.
305306
let sender_address = fvk.address_at(0u32, Scope::External);
306307
let signed_rho = Rho::from_nf_old(Nullifier(rho));
307308
let signed_note = if let Some(pre) = precomputed {
308309
let rseed = RandomSeed::from_bytes(pre.rseed_signed, &signed_rho)
309310
.expect("precomputed rseed_signed must be valid");
310-
Note::from_parts(sender_address, NoteValue::zero(), signed_rho, rseed)
311+
Note::from_parts(sender_address, NoteValue::from_raw(1), signed_rho, rseed)
311312
.expect("precomputed signed note must be valid")
312313
} else {
313314
Note::new(
314315
sender_address,
315-
NoteValue::zero(),
316+
NoteValue::from_raw(1),
316317
signed_rho,
317318
&mut *rng,
318319
)
@@ -364,6 +365,7 @@ pub fn build_delegation_bundle(
364365
nc_root,
365366
nf_imt_root,
366367
[gov_nulls[0], gov_nulls[1], gov_nulls[2], gov_nulls[3], gov_nulls[4]],
368+
dom,
367369
);
368370

369371
Ok(DelegationBundle { circuit, instance })

0 commit comments

Comments
 (0)