Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion zcash_voting/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "zcash_voting"
version = "0.2.3"
version = "0.3.0"
edition = "2021"
description = "Client-side library for Zcash shielded voting: ZKP delegation and vote-commitment proofs (Halo 2), ElGamal encryption, governance PCZT construction, Merkle witness generation, and SQLite round-state persistence."
license = "MIT OR Apache-2.0"
Expand Down
102 changes: 93 additions & 9 deletions zcash_voting/src/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,23 +97,34 @@ fn random_rseed(rng: &mut impl RngCore, rho: &Rho) -> (RandomSeed, [u8; 32]) {
}
}

/// Construct a 1-zatoshi orchard Note at the given address with the given Rho.
/// Value is 1 zatoshi so that Keystone renders the transaction on screen.
fn make_dummy_note(
/// Construct an Orchard note at the given address with the given value and Rho.
fn make_note(
addr: Address,
value: NoteValue,
rho: Rho,
rng: &mut impl RngCore,
) -> Result<(orchard::Note, [u8; 32]), VotingError> {
let (rseed, rseed_bytes) = random_rseed(rng, &rho);
let note = orchard::Note::from_parts(addr, NoteValue::from_raw(1), rho, rseed);
let note = orchard::Note::from_parts(addr, value, rho, rseed);
if !bool::from(note.is_some()) {
return Err(VotingError::Internal {
message: "failed to construct dummy note".to_string(),
message: "failed to construct note".to_string(),
});
}
Ok((note.expect("is_some checked above"), rseed_bytes))
}

/// Construct a 1-zatoshi Orchard note.
///
/// The signed note uses value 1 so Keystone renders a non-zero Orchard action.
fn make_dummy_note(
addr: Address,
rho: Rho,
rng: &mut impl RngCore,
) -> Result<(orchard::Note, [u8; 32]), VotingError> {
make_note(addr, NoteValue::from_raw(1), rho, rng)
}

/// Canonical delegate action payload encoding for external signing.
///
/// Field order:
Expand Down Expand Up @@ -234,7 +245,9 @@ pub fn build_governance_pczt(
gov_nullifiers.push(gov_null);
}

// Padded note generation (also collect rho+rseed for ZCA-74 randomness threading)
// Padded note generation (also collect rho+rseed for ZCA-74 randomness threading).
// These must match the delegation circuit builder exactly: unused note slots
// are zero-value notes at address index 1000+i.
let mut padded_cmx: Vec<Vec<u8>> = Vec::new();
let mut dummy_nullifiers: Vec<Vec<u8>> = Vec::new();
let mut padded_note_secrets: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
Expand All @@ -243,7 +256,7 @@ pub fn build_governance_pczt(
for i in n_real..5 {
let pad_addr = fvk.address_at(1000u32 + i as u32, Scope::External);
let rho = random_rho(&mut rng);
let (pad_note, rseed_bytes) = make_dummy_note(pad_addr, rho, &mut rng)?;
let (pad_note, rseed_bytes) = make_note(pad_addr, NoteValue::ZERO, rho, &mut rng)?;
let cmx: ExtractedNoteCommitment = pad_note.commitment().into();
let real_nf = pad_note.nullifier(&fvk);
let gov_null =
Expand Down Expand Up @@ -342,7 +355,8 @@ pub fn build_governance_pczt(
message: format!("Builder::add_spend failed: {:?}", e),
})?;

// Add output to hotkey address (1 zatoshi, with delegation memo)
// Add output to hotkey address. The circuit commits to a zero-value output
// note for cmx_new, so Phase 1 must use the same value and rseed.
let ovk = fvk.to_ovk(Scope::External);
let memo = {
let zec_whole = total_weight / 100_000_000;
Expand All @@ -358,7 +372,7 @@ pub fn build_governance_pczt(
buf
};
builder
.add_output(Some(ovk), hotkey_addr, NoteValue::from_raw(1), memo)
.add_output(Some(ovk), hotkey_addr, NoteValue::ZERO, memo)
.map_err(|e| VotingError::Internal {
message: format!("Builder::add_output failed: {:?}", e),
})?;
Expand Down Expand Up @@ -755,6 +769,76 @@ mod tests {
// The parsed PCZT should have 2 orchard actions (1 real + 1 padding)
let pczt = parsed.unwrap();
assert_eq!(pczt.orchard().actions().len(), 2);
let output_value = pczt
.orchard()
.actions()
.iter()
.find_map(|action| action.output().value().as_ref().copied())
.expect("PCZT should expose the output value");
assert_eq!(output_value, NoteValue::ZERO.inner());
}

#[test]
fn test_build_governance_pczt_padded_slots_match_circuit_zero_value_notes() {
let note = mock_note();
let params = mock_params();
let fvk_bytes = mock_fvk_bytes();
let result = build_governance_pczt(
&[note.clone()],
&params,
&fvk_bytes,
&mock_hotkey_address(),
NU5_BRANCH_ID,
MAINNET_COIN_TYPE,
&MOCK_SEED_FP,
MOCK_ACCOUNT,
"Test Round",
)
.unwrap();

let fvk_96: [u8; 96] = fvk_bytes.clone().try_into().unwrap();
let fvk = FullViewingKey::from_bytes(&fvk_96).unwrap();
let nk_bytes = &fvk_bytes[32..64];
let vote_round_id_bytes = hex::decode(&params.vote_round_id).unwrap();
let vri_32: [u8; 32] = vote_round_id_bytes.try_into().unwrap();
let dom = crate::governance::compute_nullifier_domain(&vri_32).unwrap();

assert_eq!(result.padded_cmx.len(), 4);
assert_eq!(result.dummy_nullifiers.len(), 4);
assert_eq!(result.padded_note_secrets.len(), 4);

for (i_pad, (rho_bytes, rseed_bytes)) in result.padded_note_secrets.iter().enumerate() {
let i_slot = 1 + i_pad;
let pad_addr = fvk.address_at((1000 + i_slot) as u32, Scope::External);
let rho_arr: [u8; 32] = rho_bytes.as_slice().try_into().unwrap();
let rseed_arr: [u8; 32] = rseed_bytes.as_slice().try_into().unwrap();
let rho = Rho::from_bytes(&rho_arr).unwrap();
let rseed = RandomSeed::from_bytes(rseed_arr, &rho).unwrap();
let pad_note =
orchard::Note::from_parts(pad_addr, NoteValue::ZERO, rho, rseed).unwrap();
let cmx: ExtractedNoteCommitment = pad_note.commitment().into();
let nf = pad_note.nullifier(&fvk);
let gov_null =
crate::governance::derive_gov_nullifier(nk_bytes, &dom, &nf.to_bytes()).unwrap();

assert_eq!(result.padded_cmx[i_pad], cmx.to_bytes().to_vec());
assert_eq!(result.dummy_nullifiers[i_pad], nf.to_bytes().to_vec());
assert_eq!(result.gov_nullifiers[i_slot], gov_null);
}

let mut all_cmx = vec![note.commitment];
all_cmx.extend(result.padded_cmx.iter().cloned());
let expected_rho_signed = crate::governance::compute_rho_binding(
&all_cmx[0],
&all_cmx[1],
&all_cmx[2],
&all_cmx[3],
&all_cmx[4],
&result.van,
&vri_32,
)
.unwrap();
assert_eq!(result.rho_signed, expected_rho_signed);
}

#[test]
Expand Down
139 changes: 126 additions & 13 deletions zcash_voting/src/storage/operations.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
use std::collections::HashMap;

use ff::PrimeField;
use orchard::{
keys::{FullViewingKey, Scope},
note::{Note, RandomSeed, Rho},
value::NoteValue,
};
use pasta_curves::pallas;
use voting_circuits::delegation::imt::ImtProofData;
use zcash_keys::keys::UnifiedFullViewingKey;
use zcash_protocol::consensus::Network;

use crate::storage::queries;
use crate::storage::{
Expand Down Expand Up @@ -71,6 +78,97 @@ fn nullifier_imt_root_to_base(bytes: &[u8]) -> Result<pallas::Base, VotingError>
})
}

/// Derive the padded-slot nullifiers the way the delegation circuit builder
/// does: `Note::from_parts(fvk.address_at(1000+i, External), NoteValue::ZERO,
/// rho, rseed)` then `note.nullifier(&fvk)`.
///
/// The `dummy_nullifiers` DB column is populated from these same zero-value
/// padded notes. This helper recomputes from the stored rho/rseed pairs so PIR
/// precompute and proof generation share the exact circuit-side derivation.
fn padded_nullifiers_for_circuit(
notes: &[NoteInfo],
padded_secrets: &[(Vec<u8>, Vec<u8>)],
network_id: u32,
) -> Result<Vec<Vec<u8>>, VotingError> {
if padded_secrets.is_empty() {
return Ok(Vec::new());
}
let n_real = notes.len();
let first_ufvk = &notes
.first()
.ok_or_else(|| VotingError::InvalidInput {
message: "notes must be non-empty to derive padded nullifiers".to_string(),
})?
.ufvk_str;

let network = match network_id {
0 => Network::MainNetwork,
1 => Network::TestNetwork,
_ => {
return Err(VotingError::InvalidInput {
message: format!(
"invalid network_id {network_id}, expected 0 (mainnet) or 1 (testnet)"
),
})
}
};
let ufvk =
UnifiedFullViewingKey::decode(&network, first_ufvk).map_err(|e| VotingError::Internal {
message: format!("failed to decode UFVK while deriving padded nullifiers: {e}"),
})?;
let fvk: FullViewingKey = ufvk
.orchard()
.ok_or_else(|| VotingError::Internal {
message: "UFVK has no Orchard component".into(),
})?
.clone();

let mut out = Vec::with_capacity(padded_secrets.len());
for (i_pad, (rho_bytes, rseed_bytes)) in padded_secrets.iter().enumerate() {
let i_slot = n_real + i_pad;
let pad_addr = fvk.address_at((1000 + i_slot) as u32, Scope::External);
let rho_arr: [u8; 32] =
rho_bytes
.as_slice()
.try_into()
.map_err(|_| VotingError::Internal {
message: format!(
"padded[{i_pad}] rho must be 32 bytes, got {}",
rho_bytes.len()
),
})?;
let rho = Option::from(Rho::from_bytes(&rho_arr)).ok_or_else(|| VotingError::Internal {
message: format!("padded[{i_pad}] rho is not a valid Rho"),
})?;
let rseed_arr: [u8; 32] =
rseed_bytes
.as_slice()
.try_into()
.map_err(|_| VotingError::Internal {
message: format!(
"padded[{i_pad}] rseed must be 32 bytes, got {}",
rseed_bytes.len()
),
})?;
let rseed = Option::from(RandomSeed::from_bytes(rseed_arr, &rho)).ok_or_else(|| {
VotingError::Internal {
message: format!("padded[{i_pad}] rseed is not valid for the stored rho"),
}
})?;
let pad_note: Note = Option::from(Note::from_parts(
pad_addr,
NoteValue::ZERO,
rho,
rseed,
))
.ok_or_else(|| VotingError::Internal {
message: format!("padded[{i_pad}] note construction failed"),
})?;
out.push(pad_note.nullifier(&fvk).to_bytes().to_vec());
}
Ok(out)
}

impl VotingDb {
// --- Round management ---

Expand Down Expand Up @@ -284,25 +382,33 @@ impl VotingDb {
// --- Phase 2: Delegation proof ---

/// Fetch and persist PIR-backed IMT non-membership proofs for every ZKP #1
/// note slot in this bundle: real notes plus padded dummy notes generated by
/// `build_governance_pczt`.
/// note slot in this bundle: real notes plus the padded note slots that the
/// delegation circuit will fill.
///
/// This is safe to run before submit/auth: it only needs the note metadata
/// already in the wallet plus the padded-note rho/rseed pairs that
/// `build_governance_pczt` already wrote to the bundles row. No spending
/// seed is required.
///
/// This is safe to run before submit/auth because it only uses note
/// nullifiers already present in wallet note metadata and dummy nullifiers
/// generated from UFVK/PCZT setup data; no spending seed is required.
/// The padded-slot nullifiers we cache are derived to match what the
/// circuit builder asks for at proof-gen time (see
/// `padded_nullifiers_for_circuit`).
pub fn precompute_delegation_pir(
&self,
round_id: &str,
bundle_index: u32,
notes: &[NoteInfo],
pir_server_url: &str,
network_id: u32,
) -> Result<DelegationPirPrecomputeResult, VotingError> {
let conn = self.conn();
let wallet_id = self.wallet_id();
let params = queries::load_round_params(&conn, round_id, &wallet_id)?;
let dummy_nullifiers =
queries::load_dummy_nullifiers(&conn, round_id, &wallet_id, bundle_index)?;
let targets = delegation_nullifier_targets(notes, &dummy_nullifiers)?;
let padded_secrets =
queries::load_padded_note_secrets(&conn, round_id, &wallet_id, bundle_index)?;
let padded_nullifiers =
padded_nullifiers_for_circuit(notes, &padded_secrets, network_id)?;
let targets = delegation_nullifier_targets(notes, &padded_nullifiers)?;

let mut cached_count = 0u32;
let mut missing = Vec::new();
Expand Down Expand Up @@ -423,8 +529,10 @@ impl VotingDb {
let rseed_output = queries::load_rseed_output(&conn, round_id, &wallet_id, bundle_index)?;
let padded_secrets =
queries::load_padded_note_secrets(&conn, round_id, &wallet_id, bundle_index)?;
let dummy_nullifiers =
queries::load_dummy_nullifiers(&conn, round_id, &wallet_id, bundle_index)?;
// These are the zero-value circuit-side padded nullifiers derived
// from the Phase 1 padded-note rho/rseed pairs.
let padded_nullifiers =
padded_nullifiers_for_circuit(notes, &padded_secrets, network_id)?;

// Align witnesses (keyed by commitment) to notes order
let witness_count = witnesses.len();
Expand Down Expand Up @@ -482,12 +590,17 @@ impl VotingDb {

// Phase 2: Load/fetch IMT exclusion proofs via PIR.
let pir_start = std::time::Instant::now();
let precompute =
self.precompute_delegation_pir(round_id, bundle_index, notes, pir_server_url)?;
let precompute = self.precompute_delegation_pir(
round_id,
bundle_index,
notes,
pir_server_url,
network_id,
)?;

let conn = self.conn();
let real_targets = delegation_nullifier_targets(notes, &[])?;
let dummy_targets = delegation_nullifier_targets(&[], &dummy_nullifiers)?;
let dummy_targets = delegation_nullifier_targets(&[], &padded_nullifiers)?;
let mut imt_proofs = Vec::with_capacity(real_targets.len());
for (nf_bytes, _) in &real_targets {
let proof = queries::load_imt_proof(
Expand Down
4 changes: 2 additions & 2 deletions zcash_voting/src/storage/queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ pub fn load_bundle_note_positions(
// we persist two values needed for later proof steps:
// - van_comm_rand: the 32-byte blinding factor used in the VAN Poseidon hash.
// Needed again in ZKP #2 (vote commitment) to reconstruct the VAN as a witness.
// - dummy_nullifiers: random nullifiers generated for padded note slots (§1.3.5).
// - dummy_nullifiers: nullifiers generated for zero-value padded note slots (§1.3.5).
// Each is 32 bytes. Stored so the witness builder can reconstruct padded notes.

/// Persist all delegation action data in a single UPDATE on the bundles table:
Expand All @@ -314,7 +314,7 @@ pub fn store_delegation_data(
padded_note_secrets: &[(Vec<u8>, Vec<u8>)],
pczt_sighash: &[u8],
) -> Result<(), VotingError> {
// Serialize dummy nullifiers as a flat byte blob: [nf0 (32 bytes) | nf1 | nf2 | ...].
// Serialize padded-note nullifiers as a flat byte blob: [nf0 (32 bytes) | nf1 | nf2 | ...].
// Length 0 means no padding was needed (all 5 notes were real).
// Length 32/64/96/128 means 1/2/3/4 dummy notes respectively.
let dummy_blob: Vec<u8> = dummy_nullifiers
Expand Down
4 changes: 2 additions & 2 deletions zcash_voting/src/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ pub struct DelegationAction {
pub van: Vec<u8>,
/// 32-byte blinding factor used for VAN (must be persisted for later use).
pub van_comm_rand: Vec<u8>,
/// Random nullifiers used for padded dummy notes (needed for circuit witness in later steps).
/// Nullifiers for zero-value padded notes (needed for circuit witness in later steps).
pub dummy_nullifiers: Vec<Vec<u8>>,
/// Constrained rho for the signed note (32 bytes). Spec §1.3.4.1.
pub rho_signed: Vec<u8>,
Expand Down Expand Up @@ -120,7 +120,7 @@ pub struct GovernancePczt {
pub van: Vec<u8>,
/// 32-byte blinding factor used for VAN (must be persisted for later use).
pub van_comm_rand: Vec<u8>,
/// Random nullifiers used for padded dummy notes (needed for circuit witness).
/// Nullifiers for zero-value padded notes (needed for circuit witness).
pub dummy_nullifiers: Vec<Vec<u8>>,
/// Constrained rho for the signed note (32 bytes). Spec §1.3.4.1.
pub rho_signed: Vec<u8>,
Expand Down
Loading