voting-circuits 0.1.0

Governance ZKP circuits (delegation, vote proof, share reveal) for the Zcash shielded-voting protocol.
Documentation
---
name: Gov Commitment and MinWeight
overview: Add conditions 7 (Gov Commitment Integrity) and 8 (Minimum Voting Weight) to the delegation circuit. Condition 7 verifies van_comm = Poseidon(g_d_new_x, pk_d_new_x, v_total, vote_round_id, van_comm_rand). Condition 8 enforces v_total >= 12,500,000 zatoshi via a 64-bit range check on the difference.
todos: []
isProject: false
---

# Gov Commitment Integrity and Minimum Voting Weight

## Recommendation on `vpk` (question #3)

`vpk` is a **full diversified address** -- the tuple `(g_d_new, pk_d_new)` -- which are the same ECC points already witnessed for the output note in condition 6. For the Poseidon hash, represent vpk as **two Pallas base field elements** by extracting x-coordinates: `g_d_new_x = ExtractP(g_d_new)` and `pk_d_new_x = ExtractP(pk_d_new)`.

This means the van_comm Poseidon becomes a **5-input hash** (`ConstantLength<5>`):

```
van_comm = Poseidon(g_d_new_x, pk_d_new_x, v_total, vote_round_id, van_comm_rand)
```

Rationale for 5 inputs vs nested hashing:
- Reuses cells already in the circuit from condition 6 (zero additional ECC ops)
- One Poseidon call instead of two (cheaper than `Poseidon(Poseidon(g_d_x, pk_d_x), v_total, ...))`)
- Both address components are explicitly bound (no reliance on subtle assumptions about point uniqueness from a single coordinate)
- The design doc's 4-input formula `Poseidon(voting_hotkey_pk, ...)` expands naturally since `voting_hotkey_pk` is a tuple

## Architecture: Per-note circuit with free-witness `v_i`

Conditions 7 and 8 stay **inside the existing per-note delegation circuit**, not in a separate aggregation circuit. This is consistent with the design doc's single-ZKP structure (conditions 1-15 all in one circuit).

The 4 note values `v_1..v_4` are added as **free private witnesses** (field elements). Today they have no in-circuit binding to actual note commitments -- that binding arrives with condition 9 (Old Note Commitment Integrity). This is safe because:

- Condition 7 binds `v_total` into `van_comm`, which is a public input
- `van_comm` enters the delegation commitment tree and is opened in ZKP #2 (vote proof), so inflating `v_total` would fail at vote time
- Condition 8 prevents dust (v_total < 0.125 ZEC) regardless of whether v_i are bound to real notes yet

## Files to change

### 1. [src/delegation/circuit.rs]src/delegation/circuit.rs -- Main changes

**Config struct** -- add `range_check` field:

```rust
range_check: LookupRangeCheckConfig<pallas::Base, { sinsemilla::K }>,
```

The `range_check` is already created at line 333 but not stored. Store it and add a helper:

```rust
fn range_check_config(&self) -> LookupRangeCheckConfig<pallas::Base, { sinsemilla::K }> {
    self.range_check
}
```

**Circuit struct** -- add 5 new private witness fields:

```rust
v_1: Value<pallas::Base>,
v_2: Value<pallas::Base>,
v_3: Value<pallas::Base>,
v_4: Value<pallas::Base>,
van_comm_rand: Value<pallas::Base>,
```

**New builder method** `with_van_commitment_data`:

```rust
pub fn with_van_commitment_data(
    mut self,
    v_1: u64, v_2: u64, v_3: u64, v_4: u64,
    van_comm_rand: pallas::Base,
) -> Self {
    self.v_1 = Value::known(pallas::Base::from(v_1));
    self.v_2 = Value::known(pallas::Base::from(v_2));
    self.v_3 = Value::known(pallas::Base::from(v_3));
    self.v_4 = Value::known(pallas::Base::from(v_4));
    self.van_comm_rand = Value::known(van_comm_rand);
    self
}
```

**Synthesize changes:**

Step A: Restructure condition 3 block (rho binding, lines 619-661) to return `van_comm_cell` and `vote_round_id_cell`:

```rust
let (van_comm_cell, vote_round_id_cell) = {
    // ... existing rho binding logic ...
    (van_comm, vote_round_id)
};
```

Step B: Restructure condition 6 block (output note commit, lines 663-734) to return `g_d_new_x` and `pk_d_new_x`:

```rust
let (g_d_new_x, pk_d_new_x) = {
    let g_d_new = NonIdentityPoint::new(...)?;
    let pk_d_new = NonIdentityPoint::new(...)?;
    // ... existing NoteCommit + cmx constraint ...
    (g_d_new.extract_p().inner().clone(), pk_d_new.extract_p().inner().clone())
};
```

Step C: Add condition 7 block after condition 6:

```rust
// Condition 7: Gov Commitment Integrity
// van_comm = Poseidon(g_d_new_x, pk_d_new_x, v_total, vote_round_id, van_comm_rand)
let v_total = {
    let v_1 = assign_free_advice(layouter, config.advices[0], self.v_1)?;
    let v_2 = assign_free_advice(layouter, config.advices[0], self.v_2)?;
    let v_3 = assign_free_advice(layouter, config.advices[0], self.v_3)?;
    let v_4 = assign_free_advice(layouter, config.advices[0], self.v_4)?;
    let van_comm_rand = assign_free_advice(layouter, config.advices[0], self.van_comm_rand)?;

    // v_total = v_1 + v_2 + v_3 + v_4  (three AddChip additions)
    let add_chip = config.add_chip();
    let sum_12 = add_chip.add(layouter, &v_1, &v_2)?;
    let sum_123 = add_chip.add(layouter, &sum_12, &v_3)?;
    let v_total = add_chip.add(layouter, &sum_123, &v_4)?;

    // Poseidon(g_d_new_x, pk_d_new_x, v_total, vote_round_id, van_comm_rand)
    let derived_van_comm = {
        let msg = [g_d_new_x, pk_d_new_x, v_total.clone(), vote_round_id_cell, van_comm_rand];
        let hasher = PoseidonHash::<
            pallas::Base, _, poseidon::P128Pow5T3, ConstantLength<5>, 3, 2,
        >::init(config.poseidon_chip(), layouter)?;
        hasher.hash(layouter, msg)?
    };

    // Constrain: derived_van_comm == van_comm (from condition 3)
    layouter.assign_region(|| "van_comm integrity", |mut region| {
        region.constrain_equal(derived_van_comm.cell(), van_comm_cell.cell())
    })?;

    v_total
};
```

Step D: Add condition 8 block after condition 7:

```rust
// Condition 8: Minimum Voting Weight
// v_total >= 12,500,000 zatoshi (0.125 ZEC)
{
    const MIN_WEIGHT: u64 = 12_500_000;

    // Witness diff = v_total - MIN_WEIGHT
    let diff = v_total.value().map(|v| v - pallas::Base::from(MIN_WEIGHT));
    let diff = assign_free_advice(layouter, config.advices[0], diff)?;

    // Constrain: diff + MIN_WEIGHT = v_total
    // Assign MIN_WEIGHT as constant, then use AddChip: diff + min = v_total
    let min_weight = assign_free_advice(
        layouter, config.advices[0],
        Value::known(pallas::Base::from(MIN_WEIGHT)),
    )?;
    let recomputed = config.add_chip().add(layouter, &diff, &min_weight)?;
    layouter.assign_region(|| "v_total = diff + min_weight", |mut region| {
        region.constrain_equal(recomputed.cell(), v_total.cell())
    })?;

    // Range-check diff to [0, 2^70) -- ensures diff is non-negative
    // (if v_total < MIN_WEIGHT, diff wraps to ~2^254, failing the check)
    // 7 words * 10 bits/word = 70 bits >= 64 bits (sufficient for u64 sums)
    config.range_check_config().copy_check(
        layouter.namespace(|| "diff < 2^70"),
        diff,
        7,    // num_words
        true, // strict (running sum terminates at 0)
    )?;
}
```

### 2. [src/spec.rs]src/spec.rs -- Out-of-circuit helper

Add a `van_commitment_hash` function next to the existing `rho_binding_hash` (line 245):

```rust
pub(crate) fn van_commitment_hash(
    g_d_new_x: pallas::Base,
    pk_d_new_x: pallas::Base,
    v_total: pallas::Base,
    vote_round_id: pallas::Base,
    van_comm_rand: pallas::Base,
) -> pallas::Base {
    poseidon::Hash::<_, poseidon::P128Pow5T3, poseidon::ConstantLength<5>, 3, 2>::init()
        .hash([g_d_new_x, pk_d_new_x, v_total, vote_round_id, van_comm_rand])
}
```

### 3. [src/delegation/README.md]src/delegation/README.md -- Documentation

Add sections for conditions 7 and 8 describing