voting-circuits 0.1.0

Governance ZKP circuits (delegation, vote proof, share reveal) for the Zcash shielded-voting protocol.
Documentation
---
name: Signed Note Commitment Integrity
overview: "Implement Condition 1 (Old Note Commitment Integrity) in the delegation circuit: compute NoteCommit in-circuit from the signed note's witness data and constrain it to equal the witnessed cm_signed. No null/bottom option — strict equality only."
todos:
  - id: add-imports
    content: "Add imports: NoteCommitChip, NoteCommitConfig, note_commit gadget, NoteCommitTrapdoor, NoteValue"
    status: completed
  - id: add-note-commit-config
    content: Add note_commit_config to Config struct; add note_commit_chip() helper; add NoteCommitChip::configure call in configure()
    status: completed
  - id: update-circuit-struct
    content: "Add rcm_signed: Value<NoteCommitTrapdoor> to Circuit struct; update from_note_unchecked to extract rcm_signed"
    status: completed
  - id: clone-rho-old
    content: Clone rho_old in derive_nullifier call so it remains available for note_commit
    status: completed
  - id: restructure-addr-integrity
    content: Change address integrity block to return pk_d_signed as NonIdentityPoint for reuse
    status: completed
  - id: add-note-commit-block
    content: "Add note commitment integrity block: witness rcm_signed + v_signed=0, call note_commit, constrain_equal to cm_old"
    status: completed
  - id: add-tests
    content: Add note_commit_integrity_happy_path, note_commit_integrity_wrong_rcm, and note_commit_integrity_wrong_cm tests
    status: completed
isProject: false
---

# Signed Note Commitment Integrity (Condition 1)

## Spec Condition

> `NoteCommit_rcm_signed(repr(g_d_signed), repr(pk_d_signed), 0, rho_signed, psi_signed) = cm_signed`
>
> The signed note's commitment is correctly constructed. **No null option** — strict equality, unlike Orchard's `∈ {cm, ⊥}`.

The `⊥` case is handled internally by `CommitDomain::commit`: incomplete addition constraints allow ⊥ to occur, and synthesis detects these edge cases and aborts proof creation. Since we don't allow ⊥, this means an invalid commitment simply prevents proof creation.

**Reference implementation**: [src/circuit.rs](src/circuit.rs) lines 616-642 (Orchard action circuit's old note commitment integrity).

## What Changes

Only one file is modified: [src/delegation/circuit.rs](src/delegation/circuit.rs). No new public inputs. `K = 12` remains sufficient.

---

### 1. Add imports

Add to the `crate::circuit` import block in [src/delegation/circuit.rs](src/delegation/circuit.rs) line 26:

- `note_commit` from `gadget` (alongside existing `assign_free_advice`, `commit_ivk`, `derive_nullifier`)
- `NoteCommitChip, NoteCommitConfig` from `note_commit` (new sub-import from `crate::circuit::note_commit`)

Add to the `crate::note::commitment` import block (line 38):

- `NoteCommitTrapdoor` (alongside existing `NoteCommitment`)

Add to the `crate` imports:

- `value::NoteValue` — needed for witnessing the zero value

### 2. Add `note_commit_config` to `Config` struct

In the `Config` struct ([circuit.rs](src/delegation/circuit.rs) lines 71-96), add:

```rust
note_commit_config: NoteCommitConfig,
```

Add helper method to `impl Config`:

```rust
fn note_commit_chip(&self) -> NoteCommitChip {
    NoteCommitChip::construct(self.note_commit_config.clone())
}
```

### 3. Add `NoteCommitChip::configure` to `configure()`

Insert after the existing `commit_ivk_config` setup ([circuit.rs](src/delegation/circuit.rs) line 255):

```rust
let note_commit_config =
    NoteCommitChip::configure(meta, advices, sinsemilla_config.clone());
```

`NoteCommitChip::configure` takes `(meta, advices, sinsemilla_config)` — it reuses the existing Sinsemilla config and 10 advice columns. It creates selectors for decomposition/canonicity gates (b, d, e, g, h decompositions; g_d, pk_d, value, rho, psi canonicity; y-coordinate canonicity).

Include `note_commit_config` in the `Config` return struct.

### 4. Add `rcm_signed` to `Circuit` struct

In the `Circuit` struct ([circuit.rs](src/delegation/circuit.rs) lines 133-144), add:

```rust
rcm_signed: Value<NoteCommitTrapdoor>,
```

`Default` derive still works: `Value<T>` defaults to `Value::unknown()`.

### 5. Update `from_note_unchecked` constructor

Extract `rcm_signed` from the note, following the Orchard pattern ([src/circuit.rs](src/circuit.rs) line 159):

```rust
let rcm_signed = note.rseed().rcm(&rho_old);
// ...
rcm_signed: Value::known(rcm_signed),
```

### 6. Add note commitment integrity block in `synthesize()`

This goes **after** the diversified address integrity block, which must be restructured to return `pk_d_signed` so it can be reused.

**Step 6a: Restructure address integrity to return `pk_d_signed**`

Currently `pk_d_signed` is scoped inside `{ ... }`. Change the block to return `pk_d_signed`, following the Orchard pattern at [src/circuit.rs](src/circuit.rs) lines 571-614:

```rust
let pk_d_signed = {
    // ... existing address integrity code ...
    pk_d_signed  // return the NonIdentityPoint
};
```

**Step 6b: Add the note commitment integrity block**

After the address integrity block, insert:

```rust
// Old note commitment integrity.
// NoteCommit_rcm_signed(repr(g_d_signed), repr(pk_d_signed), 0,
//                        rho_signed, psi_signed) = cm_signed
// No null option: the signed note must have a valid commitment.
{
    let rcm_signed = ScalarFixed::new(
        ecc_chip.clone(),
        layouter.namespace(|| "rcm_signed"),
        self.rcm_signed.as_ref().map(|rcm| rcm.inner()),
    )?;

    // The signed note's value is always 0.
    let v_signed = assign_free_advice(
        layouter.namespace(|| "v_signed = 0"),
        config.advices[0],
        Value::known(NoteValue::zero()),
    )?;

    // Compute NoteCommit from witness data.
    let derived_cm_signed = gadget::note_commit(
        layouter.namespace(|| "NoteCommit_rcm_signed(g_d, pk_d, 0, rho, psi)"),
        config.sinsemilla_chip(),
        config.ecc_chip(),
        config.note_commit_chip(),
        g_d_signed.inner(),
        pk_d_signed.inner(),
        v_signed,
        rho_old,
        psi_old,
        rcm_signed,
    )?;

    // Strict equality — no null/bottom option.
    derived_cm_signed.constrain_equal(
        layouter.namespace(|| "cm_signed integrity"),
        &cm_old,
    )?;
}
```

Key differences from Orchard:

- Value is hardcoded as `NoteValue::zero()` (the signed/dummy note always has v=0)
- Strict equality constraint (no conditional on enable_spends)
- Reuses `g_d_signed`, `pk_d_signed`, `rho_old`, `psi_old`, `cm_old` already witnessed earlier in the circuit

**Note on variable consumption**: `rho_old` and `psi_old` are consumed by `derive_nullifier` earlier. They need to be **cloned** before being passed to `derive_nullifier` so they remain available for `note_commit`. This requires changing the nullifier integrity section to clone these values.

### 7. Clone `rho_old` and `psi_old` for reuse

Currently at [circuit.rs](src/delegation/circuit.rs) lines 354-363:

```rust
let nf_old = derive_nullifier(
    ...,
    rho_old,      // consumed
    &psi_old,     // borrowed (already fine)
    &cm_old,
    nk.clone(),
)?;
```

`rho_old` is moved into `derive_nullifier`. Change to `rho_old.clone()`:

```rust
let nf_old = derive_nullifier(
    ...,
    rho_old.clone(),  // clone so rho_old remains available for note_commit
    &psi_old,
    &cm_old,
    nk.clone(),
)?;
```

### 8. Tests

**Existing tests pass without modification** — `from_note_unchecked` now populates `rcm_signed` with correct data, and the MockProver verifies all constraints.

**Add `note_commit_integrity_happy_path` test** — explicit coverage:

```rust
#[test]
fn note_commit_integrity_happy_path() {
    let (circuit, nf, rk) = make_test_note();
    let instance = Instance::from_parts(nf, rk);
    let public_inputs = instance.to_halo2_instance();
    let prover = MockProver::run(K, &circuit, vec![public_inputs]).unwrap();
    assert_eq!(prover.verify(), Ok(()));
}
```

**Add `note_commit_integrity_wrong_rcm` test** — wrong commitment randomness:

```rust
#[test]
fn note_commit_integrity_wrong_rcm() {
    let mut rng = OsRng;
    let (_sk, fvk, note) = Note::dummy(&mut rng, None);
    let nf = note.nullifier(&fvk);
    let ak: SpendValidatingKey = fvk.clone().into();
    let alpha = pallas::Scalar::random(&mut rng);
    let rk = ak.randomize(&alpha);
    let mut circuit = Circuit::from_note_unchecked(&fvk, &note, alpha);

    // Replace rcm_signed with a random trapdoor
    use crate::note::commitment::NoteCommitTrapdoor;
    circuit.rcm_signed = Value::known(NoteCommitTrapdoor(pallas::Scalar::random(&mut rng)));

    let instance = Instance::from_parts(nf, rk);
    let public_inputs = instance.to_halo2_instance();
    let prover = MockProver::run(K, &circuit, vec![public_inputs]).unwrap();
    assert!(prover.verify().is_err());
}
```

**Add `note_commit_integrity_wrong_cm` test** — correct inputs but wrong witnessed cm:

```rust
#[test]
fn note_commit_integrity_wrong_cm() {
    let mut rng = OsRng;
    let (_sk1, fvk1, note1) = Note::dummy(&mut rng, None);
    let (_sk2, _fvk2, note2) = Note::dummy(&mut rng, None);

    // Build circuit from note1 but use note2's commitment
    let nf = note1.nullifier(&fvk1);
    let ak: SpendValidatingKey = fvk1.clone().into();
    let alpha = pallas::Scalar::random(&mut rng);
    let rk = ak.randomize(&alpha);
    let mut circuit = Circuit::from_note_unchecked(&fvk1, &note1, alpha);
    circuit.cm_old = Value::known(note2.commitment());

    let instance = Instance::from_parts(nf, rk);
    let public_inputs = instance.to_halo2_instance();
    let prover = MockProver::run(K, &circuit, vec![public_inputs]).unwrap();
    assert!(prover.verify().is_err());
}
```

This test verifies that both the note commitment integrity AND nullifier integrity fail when `cm_old` is tampered with (since the nullifier is derived from `cm_old` too).

### Circuit Size

NoteCommit adds approximately:

- Sinsemilla hash of message pieces: ~500-600 rows
- Decomposition and canonicity checks: ~200-300 rows
- Scalar fixed multiplication (rcm): ~255 rows

**Total addition**: ~950-1150 rows. Combined with existing ~1130-1230 rows, the circuit uses approximately ~2080-2380 rows out of the 4096-row budget at K=12. This leaves headroom for future conditions (Merkle path, SMT non-membership, governance nullifiers).

### Ordering of changes

Apply changes in this order to maintain a compilable circuit at each step:

1. **Imports** (step 1)
2. **Config struct + helpers** (steps 2-3) — adds `note_commit_config` plumbing
3. **Circuit struct + constructor** (steps 4-5) — adds `rcm_signed` witness
4. **Clone rho_old** (step 7) — needed before note_commit can consume it
5. **Restructure address integrity** (step 6a) — return `pk_d_signed`
6. **Note commitment integrity block** (step 6b) — adds the new constraint
7. **Tests** (step 8) — adds explicit coverage