diff --git a/Cargo.lock b/Cargo.lock index b1261260..80f0d6a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3731,7 +3731,7 @@ dependencies = [ [[package]] name = "zcash_voting" -version = "0.2.3" +version = "0.3.0" dependencies = [ "blake2b_simd", "ff", diff --git a/zcash_voting/Cargo.toml b/zcash_voting/Cargo.toml index c731fe2e..d564aa68 100644 --- a/zcash_voting/Cargo.toml +++ b/zcash_voting/Cargo.toml @@ -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" diff --git a/zcash_voting/src/action.rs b/zcash_voting/src/action.rs index 4a2ddd8c..41a1e3e9 100644 --- a/zcash_voting/src/action.rs +++ b/zcash_voting/src/action.rs @@ -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: @@ -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::new(); let mut dummy_nullifiers: Vec> = Vec::new(); let mut padded_note_secrets: Vec<(Vec, Vec)> = Vec::new(); @@ -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 = @@ -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; @@ -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), })?; @@ -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()], + ¶ms, + &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(¶ms.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] diff --git a/zcash_voting/src/storage/operations.rs b/zcash_voting/src/storage/operations.rs index 6f6d169f..e2d58ceb 100644 --- a/zcash_voting/src/storage/operations.rs +++ b/zcash_voting/src/storage/operations.rs @@ -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::{ @@ -71,6 +78,97 @@ fn nullifier_imt_root_to_base(bytes: &[u8]) -> Result }) } +/// 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, Vec)], + network_id: u32, +) -> Result>, VotingError> { + if padded_secrets.is_empty() { + return Ok(Vec::new()); + } + let n_real = notes.len(); + let first_ufvk = ¬es + .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 --- @@ -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 { 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(); @@ -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(); @@ -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( diff --git a/zcash_voting/src/storage/queries.rs b/zcash_voting/src/storage/queries.rs index 4b9bf1ee..696377d8 100644 --- a/zcash_voting/src/storage/queries.rs +++ b/zcash_voting/src/storage/queries.rs @@ -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: @@ -314,7 +314,7 @@ pub fn store_delegation_data( padded_note_secrets: &[(Vec, Vec)], 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 = dummy_nullifiers diff --git a/zcash_voting/src/types.rs b/zcash_voting/src/types.rs index b1034cd7..789824bd 100644 --- a/zcash_voting/src/types.rs +++ b/zcash_voting/src/types.rs @@ -75,7 +75,7 @@ pub struct DelegationAction { pub van: Vec, /// 32-byte blinding factor used for VAN (must be persisted for later use). pub van_comm_rand: Vec, - /// 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>, /// Constrained rho for the signed note (32 bytes). Spec §1.3.4.1. pub rho_signed: Vec, @@ -120,7 +120,7 @@ pub struct GovernancePczt { pub van: Vec, /// 32-byte blinding factor used for VAN (must be persisted for later use). pub van_comm_rand: Vec, - /// 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>, /// Constrained rho for the signed note (32 bytes). Spec §1.3.4.1. pub rho_signed: Vec,