use orchard::note::ExtractedNoteCommitment;
use subtle::CtOption;
use thiserror::Error;
use zcash_keys::keys::UnifiedFullViewingKey;
use zcash_protocol::consensus;
use zip32::Scope;
#[derive(Debug, Error)]
pub enum VotingError {
#[error("Invalid input: {message}")]
InvalidInput { message: String },
#[error("Proof generation failed: {message}")]
ProofFailed { message: String },
#[error("Internal error: {message}")]
Internal { message: String },
}
pub fn ct_option_to_result<T>(opt: CtOption<T>, msg: &str) -> Result<T, VotingError> {
if opt.is_some().into() {
Ok(opt.unwrap())
} else {
Err(VotingError::Internal {
message: msg.to_string(),
})
}
}
#[derive(Clone, Debug)]
pub struct VotingHotkey {
pub secret_key: Vec<u8>,
pub public_key: Vec<u8>,
pub address: String,
}
#[derive(Clone, Debug)]
pub struct NoteInfo {
pub commitment: Vec<u8>,
pub nullifier: Vec<u8>,
pub value: u64,
pub position: u64,
pub diversifier: Vec<u8>,
pub rho: Vec<u8>,
pub rseed: Vec<u8>,
pub scope: u32,
pub ufvk_str: String,
}
impl NoteInfo {
pub fn from_orchard_note<P: consensus::Parameters>(
note: &orchard::note::Note,
position: u64,
scope: Scope,
ufvk: &UnifiedFullViewingKey,
network: &P,
) -> Result<Self, VotingError> {
let fvk = ufvk.orchard().ok_or_else(|| VotingError::InvalidInput {
message: "ufvk has no Orchard component".to_string(),
})?;
let nullifier = note.nullifier(fvk);
let commitment: ExtractedNoteCommitment = note.commitment().into();
let scope = match scope {
Scope::External => 0,
Scope::Internal => 1,
};
Ok(Self {
commitment: commitment.to_bytes().to_vec(),
nullifier: nullifier.to_bytes().to_vec(),
value: note.value().inner(),
position,
diversifier: note.recipient().diversifier().as_array().to_vec(),
rho: note.rho().to_bytes().to_vec(),
rseed: note.rseed().as_bytes().to_vec(),
scope,
ufvk_str: ufvk.encode(network),
})
}
}
#[derive(Clone, Debug)]
pub struct VotingRoundParams {
pub vote_round_id: String,
pub snapshot_height: u64,
pub ea_pk: Vec<u8>,
pub nc_root: Vec<u8>,
pub nullifier_imt_root: Vec<u8>,
}
#[derive(Clone, Debug)]
pub struct DelegationAction {
pub action_bytes: Vec<u8>,
pub rk: Vec<u8>,
pub gov_nullifiers: Vec<Vec<u8>>,
pub van: Vec<u8>,
pub van_comm_rand: Vec<u8>,
pub dummy_nullifiers: Vec<Vec<u8>>,
pub rho_signed: Vec<u8>,
pub padded_cmx: Vec<Vec<u8>>,
pub nf_signed: Vec<u8>,
pub cmx_new: Vec<u8>,
pub alpha: Vec<u8>,
pub spend_auth_sig: Option<Vec<u8>>,
pub rseed_signed: Vec<u8>,
pub rseed_output: Vec<u8>,
}
#[derive(Clone, Debug)]
pub struct GovernancePczt {
pub pczt_bytes: Vec<u8>,
pub rk: Vec<u8>,
pub alpha: Vec<u8>,
pub nf_signed: Vec<u8>,
pub cmx_new: Vec<u8>,
pub gov_nullifiers: Vec<Vec<u8>>,
pub van: Vec<u8>,
pub van_comm_rand: Vec<u8>,
pub dummy_nullifiers: Vec<Vec<u8>>,
pub rho_signed: Vec<u8>,
pub padded_cmx: Vec<Vec<u8>>,
pub rseed_signed: Vec<u8>,
pub rseed_output: Vec<u8>,
pub action_bytes: Vec<u8>,
pub action_index: usize,
pub padded_note_secrets: Vec<(Vec<u8>, Vec<u8>)>,
pub pczt_sighash: Vec<u8>,
}
#[derive(Clone, Debug)]
pub struct EncryptedShare {
pub c1: Vec<u8>,
pub c2: Vec<u8>,
pub share_index: u32,
pub plaintext_value: u64,
pub randomness: Vec<u8>,
}
#[derive(Clone, Debug)]
pub struct VoteCommitmentBundle {
pub van_nullifier: Vec<u8>,
pub vote_authority_note_new: Vec<u8>,
pub vote_commitment: Vec<u8>,
pub proposal_id: u32,
pub proof: Vec<u8>,
pub enc_shares: Vec<EncryptedShare>,
pub anchor_height: u32,
pub vote_round_id: String,
pub shares_hash: Vec<u8>,
pub share_blinds: Vec<Vec<u8>>,
pub share_comms: Vec<Vec<u8>>,
pub r_vpk_bytes: Vec<u8>,
pub alpha_v: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct WireEncryptedShare {
pub c1: Vec<u8>,
pub c2: Vec<u8>,
pub share_index: u32,
}
impl From<&EncryptedShare> for WireEncryptedShare {
fn from(s: &EncryptedShare) -> Self {
Self {
c1: s.c1.clone(),
c2: s.c2.clone(),
share_index: s.share_index,
}
}
}
impl From<EncryptedShare> for WireEncryptedShare {
fn from(s: EncryptedShare) -> Self {
Self {
c1: s.c1,
c2: s.c2,
share_index: s.share_index,
}
}
}
#[derive(Clone, Debug)]
pub struct SharePayload {
pub shares_hash: Vec<u8>,
pub proposal_id: u32,
pub vote_decision: u32,
pub enc_share: WireEncryptedShare,
pub tree_position: u64,
pub all_enc_shares: Vec<WireEncryptedShare>,
pub share_comms: Vec<Vec<u8>>,
pub primary_blind: Vec<u8>,
}
#[derive(Clone, Debug)]
pub struct ShareDelegationRecord {
pub round_id: String,
pub bundle_index: u32,
pub proposal_id: u32,
pub share_index: u32,
pub sent_to_urls: Vec<String>,
pub nullifier: Vec<u8>,
pub confirmed: bool,
pub submit_at: u64,
pub created_at: u64,
}
#[derive(Clone, Debug)]
pub struct CastVoteSignature {
pub vote_auth_sig: Vec<u8>,
}
#[derive(Clone, Debug)]
pub struct DelegationSubmissionData {
pub proof: Vec<u8>,
pub rk: Vec<u8>,
pub nf_signed: Vec<u8>,
pub cmx_new: Vec<u8>,
pub gov_comm: Vec<u8>,
pub gov_nullifiers: Vec<Vec<u8>>,
pub alpha: Vec<u8>,
pub vote_round_id: String,
pub spend_auth_sig: Vec<u8>,
pub sighash: Vec<u8>,
}
#[derive(Clone, Debug)]
pub struct DelegationProofResult {
pub proof: Vec<u8>,
pub public_inputs: Vec<Vec<u8>>,
pub nf_signed: Vec<u8>,
pub cmx_new: Vec<u8>,
pub gov_nullifiers: Vec<Vec<u8>>,
pub van_comm: Vec<u8>,
pub rk: Vec<u8>,
}
#[derive(Clone, Debug)]
pub struct DelegationPirPrecomputeResult {
pub cached_count: u32,
pub fetched_count: u32,
}
#[derive(Clone, Debug)]
pub struct WitnessData {
pub note_commitment: Vec<u8>,
pub position: u64,
pub root: Vec<u8>,
pub auth_path: Vec<Vec<u8>>,
}
pub trait ProofProgressReporter: Send + Sync {
fn on_progress(&self, progress: f64);
}
pub struct NoopProgressReporter;
impl ProofProgressReporter for NoopProgressReporter {
fn on_progress(&self, _progress: f64) {}
}
pub fn validate_32_bytes(v: &[u8], name: &str) -> Result<(), VotingError> {
if v.len() != 32 {
return Err(VotingError::InvalidInput {
message: format!("{} must be 32 bytes, got {}", name, v.len()),
});
}
Ok(())
}
pub fn validate_share_index(index: u32) -> Result<(), VotingError> {
if index > 15 {
return Err(VotingError::InvalidInput {
message: format!("share_index must be 0..15, got {}", index),
});
}
Ok(())
}
pub fn validate_vote_decision(decision: u32, num_options: u32) -> Result<(), VotingError> {
if decision >= num_options {
return Err(VotingError::InvalidInput {
message: format!(
"vote_decision must be in [0, {}), got {}",
num_options, decision
),
});
}
Ok(())
}
pub fn validate_notes(notes: &[NoteInfo]) -> Result<(), VotingError> {
if notes.is_empty() || notes.len() > 5 {
return Err(VotingError::InvalidInput {
message: format!("notes must have 1..5 entries, got {}", notes.len()),
});
}
for (i, note) in notes.iter().enumerate() {
validate_32_bytes(¬e.commitment, &format!("notes[{}].commitment", i))?;
validate_32_bytes(¬e.nullifier, &format!("notes[{}].nullifier", i))?;
}
Ok(())
}
pub fn validate_round_params(params: &VotingRoundParams) -> Result<(), VotingError> {
validate_32_bytes(¶ms.ea_pk, "ea_pk")?;
validate_32_bytes(¶ms.nc_root, "nc_root")?;
validate_32_bytes(¶ms.nullifier_imt_root, "nullifier_imt_root")?;
Ok(())
}
pub fn validate_notes_for_round(notes: &[NoteInfo]) -> Result<(), VotingError> {
if notes.is_empty() {
return Err(VotingError::InvalidInput {
message: "notes must not be empty".to_string(),
});
}
for (i, note) in notes.iter().enumerate() {
validate_32_bytes(¬e.commitment, &format!("notes[{}].commitment", i))?;
validate_32_bytes(¬e.nullifier, &format!("notes[{}].nullifier", i))?;
}
Ok(())
}
#[derive(Clone, Debug)]
pub struct ChunkResult {
pub bundles: Vec<Vec<NoteInfo>>,
pub eligible_weight: u64,
pub dropped_count: usize,
}
pub fn chunk_notes(notes: &[NoteInfo]) -> ChunkResult {
use crate::governance::BALLOT_DIVISOR;
if notes.is_empty() {
return ChunkResult {
bundles: vec![],
eligible_weight: 0,
dropped_count: 0,
};
}
let mut sorted = notes.to_vec();
sorted.sort_by(|a, b| b.value.cmp(&a.value).then(a.position.cmp(&b.position)));
let mut bundle_notes: Vec<Vec<NoteInfo>> = Vec::new();
let mut bundle_totals: Vec<u64> = Vec::new();
for note in &sorted {
if bundle_notes.is_empty() || bundle_notes.last().unwrap().len() >= 5 {
bundle_notes.push(Vec::new());
bundle_totals.push(0);
}
let last = bundle_notes.len() - 1;
bundle_totals[last] += note.value;
bundle_notes[last].push(note.clone());
}
let total_notes: usize = bundle_notes.iter().map(|b| b.len()).sum();
let mut surviving: Vec<(u64, Vec<NoteInfo>)> = Vec::new();
let mut eligible_weight: u64 = 0;
let mut surviving_notes: usize = 0;
for (i, bundle) in bundle_notes.into_iter().enumerate() {
if bundle_totals[i] >= BALLOT_DIVISOR {
surviving_notes += bundle.len();
eligible_weight += (bundle_totals[i] / BALLOT_DIVISOR) * BALLOT_DIVISOR;
surviving.push((bundle_totals[i], bundle));
}
}
let dropped_count = total_notes - surviving_notes;
for (_, bundle) in &mut surviving {
bundle.sort_by_key(|n| n.position);
}
surviving.sort_by(|a, b| {
b.0.cmp(&a.0).then_with(|| {
let a_pos = a.1.first().map(|n| n.position).unwrap_or(u64::MAX);
let b_pos = b.1.first().map(|n| n.position).unwrap_or(u64::MAX);
a_pos.cmp(&b_pos)
})
});
let surviving: Vec<Vec<NoteInfo>> = surviving.into_iter().map(|(_, b)| b).collect();
ChunkResult {
bundles: surviving,
eligible_weight,
dropped_count,
}
}
#[cfg(test)]
mod tests {
use super::*;
use orchard::note::{ExtractedNoteCommitment, Rho};
use orchard::value::NoteValue;
use rand::rngs::OsRng;
use zcash_keys::keys::UnifiedSpendingKey;
use zcash_protocol::consensus::TEST_NETWORK;
use zip32::{AccountId, Scope};
fn make_note(value: u64, position: u64) -> NoteInfo {
NoteInfo {
commitment: vec![0x01; 32],
nullifier: vec![0x02; 32],
value,
position,
diversifier: vec![0; 11],
rho: vec![0; 32],
rseed: vec![0; 32],
scope: 0,
ufvk_str: String::new(),
}
}
#[test]
fn from_orchard_note_populates_note_info() {
let seed = [0x42u8; 32];
let account = AccountId::try_from(0u32).unwrap();
let usk = UnifiedSpendingKey::from_seed(&TEST_NETWORK, &seed, account).unwrap();
let ufvk = usk.to_unified_full_viewing_key();
let fvk = ufvk.orchard().unwrap().clone();
let address = fvk.address_at(0u32, Scope::External);
let mut rng = OsRng;
let (_, _, parent_note) = orchard::Note::dummy(&mut rng, None);
let note = orchard::Note::new(
address,
NoteValue::from_raw(12_500_000),
Rho::from_nf_old(parent_note.nullifier(&fvk)),
&mut rng,
);
let note_info =
NoteInfo::from_orchard_note(¬e, 42, Scope::External, &ufvk, &TEST_NETWORK).unwrap();
let commitment: ExtractedNoteCommitment = note.commitment().into();
assert_eq!(note_info.commitment, commitment.to_bytes().to_vec());
assert_eq!(
note_info.nullifier,
note.nullifier(&fvk).to_bytes().to_vec()
);
assert_eq!(note_info.value, 12_500_000);
assert_eq!(note_info.position, 42);
assert_eq!(
note_info.diversifier,
note.recipient().diversifier().as_array().to_vec()
);
assert_eq!(note_info.rho, note.rho().to_bytes().to_vec());
assert_eq!(note_info.rseed, note.rseed().as_bytes().to_vec());
assert_eq!(note_info.scope, 0);
assert_eq!(note_info.ufvk_str, ufvk.encode(&TEST_NETWORK));
}
#[test]
fn test_chunk_notes_all_valid() {
let notes: Vec<NoteInfo> = (0..5).map(|i| make_note(13_000_000, i)).collect();
let result = chunk_notes(¬es);
assert_eq!(result.bundles.len(), 1);
assert_eq!(result.dropped_count, 0);
assert_eq!(result.eligible_weight, 62_500_000);
assert_eq!(result.bundles[0].len(), 5);
}
#[test]
fn test_chunk_notes_dust_dropped() {
let notes = vec![
make_note(13_000_000, 0),
make_note(100, 1),
make_note(100, 2),
make_note(100, 3),
make_note(100, 4),
make_note(100, 5),
];
let result = chunk_notes(¬es);
assert_eq!(result.bundles.len(), 1);
assert_eq!(result.dropped_count, 1);
assert_eq!(result.eligible_weight, 12_500_000);
assert_eq!(result.bundles[0].len(), 5);
}
#[test]
fn test_chunk_notes_all_dust_empty() {
let notes = vec![make_note(100, 0), make_note(200, 1), make_note(300, 2)];
let result = chunk_notes(¬es);
assert!(result.bundles.is_empty());
assert_eq!(result.eligible_weight, 0);
assert_eq!(result.dropped_count, 3);
}
#[test]
fn test_chunk_notes_exact_threshold() {
let notes = vec![make_note(12_500_000, 0)];
let result = chunk_notes(¬es);
assert_eq!(result.bundles.len(), 1);
assert_eq!(result.eligible_weight, 12_500_000);
assert_eq!(result.dropped_count, 0);
}
#[test]
fn test_chunk_notes_single_note() {
let notes = vec![make_note(50_000_000, 42)];
let result = chunk_notes(¬es);
assert_eq!(result.bundles.len(), 1);
assert_eq!(result.bundles[0].len(), 1);
assert_eq!(result.bundles[0][0].position, 42);
assert_eq!(result.eligible_weight, 50_000_000);
}
#[test]
fn test_chunk_notes_deterministic() {
let notes: Vec<NoteInfo> = (0..7)
.map(|i| make_note(15_000_000 + i * 1_000_000, i))
.collect();
let r1 = chunk_notes(¬es);
let r2 = chunk_notes(¬es);
assert_eq!(r1.bundles.len(), r2.bundles.len());
for (b1, b2) in r1.bundles.iter().zip(r2.bundles.iter()) {
let p1: Vec<u64> = b1.iter().map(|n| n.position).collect();
let p2: Vec<u64> = b2.iter().map(|n| n.position).collect();
assert_eq!(p1, p2, "bundle positions must be deterministic");
}
}
#[test]
fn test_chunk_notes_position_ordering_within_bundles() {
let notes = vec![
make_note(20_000_000, 5),
make_note(20_000_000, 1),
make_note(20_000_000, 3),
make_note(20_000_000, 7),
make_note(20_000_000, 2),
];
let result = chunk_notes(¬es);
for bundle in &result.bundles {
for window in bundle.windows(2) {
assert!(
window[0].position < window[1].position,
"notes within bundle must be sorted by position"
);
}
}
}
#[test]
fn test_chunk_notes_bundles_sorted_by_value_desc() {
let notes: Vec<NoteInfo> = (0..8).map(|i| make_note(15_000_000, i)).collect();
let result = chunk_notes(¬es);
assert_eq!(result.bundles.len(), 2);
let totals: Vec<u64> = result
.bundles
.iter()
.map(|b| b.iter().map(|n| n.value).sum())
.collect();
assert!(
totals[0] >= totals[1],
"bundle 0 total ({}) must be >= bundle 1 total ({})",
totals[0],
totals[1]
);
let min_positions: Vec<u64> = result
.bundles
.iter()
.map(|b| b.first().unwrap().position)
.collect();
assert!(
min_positions[0] < min_positions[1],
"equal-total bundles should be ordered by min position"
);
}
#[test]
fn test_chunk_notes_largest_bundle_first() {
let mut notes = Vec::new();
for i in 0..5 {
notes.push(make_note(50_000_000, 10 + i));
}
for i in 0..5 {
notes.push(make_note(13_000_000, i));
}
let result = chunk_notes(¬es);
assert_eq!(result.bundles.len(), 2);
let total_0: u64 = result.bundles[0].iter().map(|n| n.value).sum();
let total_1: u64 = result.bundles[1].iter().map(|n| n.value).sum();
assert_eq!(total_0, 250_000_000);
assert_eq!(total_1, 65_000_000);
assert!(
total_0 > total_1,
"bundle 0 must have higher total than bundle 1"
);
}
#[test]
fn test_chunk_notes_empty() {
let result = chunk_notes(&[]);
assert!(result.bundles.is_empty());
assert_eq!(result.eligible_weight, 0);
assert_eq!(result.dropped_count, 0);
}
#[test]
fn test_chunk_notes_max_5_per_bundle() {
let notes: Vec<NoteInfo> = (0..12).map(|i| make_note(15_000_000, i)).collect();
let result = chunk_notes(¬es);
for bundle in &result.bundles {
assert!(
bundle.len() <= 5,
"bundle has {} notes, max is 5",
bundle.len()
);
}
}
}
pub fn validate_encrypted_shares(shares: &[WireEncryptedShare]) -> Result<(), VotingError> {
for (i, share) in shares.iter().enumerate() {
validate_32_bytes(&share.c1, &format!("enc_shares[{}].c1", i))?;
validate_32_bytes(&share.c2, &format!("enc_shares[{}].c2", i))?;
validate_share_index(share.share_index)?;
}
Ok(())
}