use ff::{Field, PrimeField};
use pasta_curves::arithmetic::{CurveAffine, CurveExt};
use pasta_curves::group::{Curve, Group, GroupEncoding};
use pasta_curves::pallas;
use rand::RngCore;
use subtle::CtOption;
use orchard::builder::{Builder, BundleType};
use orchard::keys::FullViewingKey;
use orchard::note::{ExtractedNoteCommitment, RandomSeed, Rho};
use orchard::pczt::Zip32Derivation;
use orchard::tree::{MerkleHashOrchard, MerklePath};
use orchard::value::NoteValue;
use orchard::{Anchor, Address};
use zcash_primitives::transaction::builder::PcztParts;
use zcash_primitives::transaction::TxVersion;
use zcash_protocol::consensus::{BlockHeight, BranchId, Network};
use zip32::Scope;
const MERKLE_DEPTH: usize = 32;
use crate::governance;
use crate::types::{
validate_notes, validate_round_params, GovernancePczt, NoteInfo, VotingError,
VotingRoundParams,
};
const ORCHARD_GD_PERSONALIZATION: &str = "z.cash:Orchard-gd";
fn point_x_bytes(point: &pallas::Point) -> Result<[u8; 32], VotingError> {
point
.to_affine()
.coordinates()
.map(|coords| coords.x().to_repr())
.into_option()
.ok_or_else(|| VotingError::InvalidInput {
message: "point is identity; x-coordinate unavailable".to_string(),
})
}
pub fn derive_hotkey_x_coords_from_raw_address(
hotkey_raw_address: &[u8; 43],
) -> Result<([u8; 32], [u8; 32]), VotingError> {
let diversifier: [u8; 11] = hotkey_raw_address[..11]
.try_into()
.expect("slice length is fixed to 11");
let pk_d_bytes: [u8; 32] = hotkey_raw_address[11..]
.try_into()
.expect("slice length is fixed to 32");
let pk_d_point: pallas::Point = pallas::Point::from_bytes(&pk_d_bytes)
.into_option()
.ok_or_else(|| VotingError::InvalidInput {
message: "hotkey_raw_address contains invalid pk_d point encoding".to_string(),
})?;
let pk_d_x: [u8; 32] = point_x_bytes(&pk_d_point)?;
let hasher = pallas::Point::hash_to_curve(ORCHARD_GD_PERSONALIZATION);
let mut g_d_point = hasher(&diversifier);
if bool::from(g_d_point.is_identity()) {
g_d_point = hasher(&[]);
}
let g_d_x: [u8; 32] = point_x_bytes(&g_d_point)?;
Ok((g_d_x, pk_d_x))
}
fn random_rho(rng: &mut impl RngCore) -> Rho {
loop {
let mut rho_bytes = [0u8; 32];
rng.fill_bytes(&mut rho_bytes);
let r: CtOption<Rho> = Rho::from_bytes(&rho_bytes);
if r.is_some().into() {
return r.expect("is_some checked above");
}
}
}
fn random_rseed(rng: &mut impl RngCore, rho: &Rho) -> (RandomSeed, [u8; 32]) {
loop {
let mut rseed_bytes = [0u8; 32];
rng.fill_bytes(&mut rseed_bytes);
let rs: CtOption<RandomSeed> = RandomSeed::from_bytes(rseed_bytes, rho);
if rs.is_some().into() {
return (rs.expect("is_some checked above"), rseed_bytes);
}
}
}
fn make_dummy_note(
addr: Address,
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);
if !bool::from(note.is_some()) {
return Err(VotingError::Internal {
message: "failed to construct dummy note".to_string(),
});
}
Ok((note.expect("is_some checked above"), rseed_bytes))
}
fn encode_delegation_action_bytes(
nf_signed: &[u8; 32],
rk: &[u8; 32],
cmx_new: &[u8; 32],
van_comm: &[u8],
gov_nullifiers: &[Vec<u8>],
vote_round_id: &[u8; 32],
) -> Result<Vec<u8>, VotingError> {
crate::types::validate_32_bytes(van_comm, "van_comm")?;
if gov_nullifiers.len() != 5 {
return Err(VotingError::InvalidInput {
message: format!(
"gov_nullifiers must have exactly 5 entries, got {}",
gov_nullifiers.len()
),
});
}
let mut out = Vec::with_capacity(32 * 10);
out.extend_from_slice(nf_signed);
out.extend_from_slice(rk);
out.extend_from_slice(cmx_new);
out.extend_from_slice(van_comm);
for (i, gn) in gov_nullifiers.iter().enumerate() {
crate::types::validate_32_bytes(gn, &format!("gov_nullifiers[{}]", i))?;
out.extend_from_slice(gn);
}
out.extend_from_slice(vote_round_id);
Ok(out)
}
pub fn build_governance_pczt(
notes: &[NoteInfo],
params: &VotingRoundParams,
fvk_bytes: &[u8],
hotkey_raw_address: &[u8],
consensus_branch_id: u32,
coin_type: u32,
seed_fingerprint: &[u8; 32],
account_index: u32,
round_name: &str,
) -> Result<GovernancePczt, VotingError> {
validate_notes(notes)?;
validate_round_params(params)?;
let fvk_96: [u8; 96] = fvk_bytes
.try_into()
.map_err(|_| VotingError::InvalidInput {
message: format!("fvk_bytes must be 96 bytes, got {}", fvk_bytes.len()),
})?;
let fvk = FullViewingKey::from_bytes(&fvk_96).ok_or_else(|| VotingError::InvalidInput {
message: "fvk_bytes is not a valid orchard FullViewingKey".to_string(),
})?;
let nk_bytes = &fvk_bytes[32..64];
let addr_43: [u8; 43] =
hotkey_raw_address
.try_into()
.map_err(|_| VotingError::InvalidInput {
message: format!(
"hotkey_raw_address must be 43 bytes, got {}",
hotkey_raw_address.len()
),
})?;
let hotkey_addr: Address = Address::from_raw_address_bytes(&addr_43)
.into_option()
.ok_or_else(|| VotingError::InvalidInput {
message: "hotkey_raw_address is not a valid orchard address".to_string(),
})?;
let (derived_g_d_new_x, derived_pk_d_new_x) =
derive_hotkey_x_coords_from_raw_address(&addr_43)?;
let vote_round_id_bytes =
hex::decode(¶ms.vote_round_id).map_err(|e| VotingError::InvalidInput {
message: format!("vote_round_id is not valid hex: {}", e),
})?;
crate::types::validate_32_bytes(&vote_round_id_bytes, "vote_round_id (decoded hex)")?;
let vri_32: [u8; 32] = vote_round_id_bytes
.try_into()
.expect("validated as 32 bytes above");
let mut rng = rand::thread_rng();
let dom = governance::compute_nullifier_domain(&vri_32)?;
let mut gov_nullifiers: Vec<Vec<u8>> = Vec::with_capacity(5);
for note in notes {
let gov_null = governance::derive_gov_nullifier(nk_bytes, &dom, ¬e.nullifier)?;
gov_nullifiers.push(gov_null);
}
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();
let n_real = notes.len();
if n_real < 5 {
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 cmx: ExtractedNoteCommitment = pad_note.commitment().into();
let real_nf = pad_note.nullifier(&fvk);
let gov_null =
governance::derive_gov_nullifier(nk_bytes, &dom, &real_nf.to_bytes())?;
padded_cmx.push(cmx.to_bytes().to_vec());
gov_nullifiers.push(gov_null);
dummy_nullifiers.push(real_nf.to_bytes().to_vec());
let rho_bytes: [u8; 32] = rho.to_bytes();
padded_note_secrets.push((rho_bytes.to_vec(), rseed_bytes.to_vec()));
}
}
let total_weight: u64 = notes
.iter()
.try_fold(0u64, |acc, n| acc.checked_add(n.value))
.ok_or_else(|| VotingError::InvalidInput {
message: "total note weight overflows u64".to_string(),
})?;
let van_comm_rand_fp = pallas::Base::random(&mut rng);
let van_comm_rand: [u8; 32] = van_comm_rand_fp.to_repr();
let van = governance::construct_van(
&derived_g_d_new_x,
&derived_pk_d_new_x,
total_weight,
&vri_32,
&van_comm_rand,
)?;
let mut all_cmx: Vec<Vec<u8>> = Vec::with_capacity(5);
for note in notes {
all_cmx.push(note.commitment.clone());
}
all_cmx.extend(padded_cmx.iter().cloned());
if all_cmx.len() != 5 {
return Err(VotingError::Internal {
message: format!("expected 5 cmx values, got {}", all_cmx.len()),
});
}
let rho_signed = governance::compute_rho_binding(
&all_cmx[0],
&all_cmx[1],
&all_cmx[2],
&all_cmx[3],
&all_cmx[4],
&van,
&vri_32,
)?;
let rho_signed_32: [u8; 32] = rho_signed
.clone()
.try_into()
.expect("rho_signed is 32 bytes from compute_rho_binding");
let rho_for_note: Rho = Rho::from_bytes(&rho_signed_32)
.into_option()
.ok_or_else(|| VotingError::Internal {
message: "rho_signed is not a valid Pallas field element for Rho".to_string(),
})?;
let sender_address = fvk.address_at(0u32, Scope::External);
let (signed_note, rseed_signed_bytes) =
make_dummy_note(sender_address, rho_for_note, &mut rng)?;
let dummy_auth_path: [MerkleHashOrchard; MERKLE_DEPTH] = {
let zero_hash = MerkleHashOrchard::from_bytes(&[0u8; 32])
.into_option()
.ok_or_else(|| VotingError::Internal {
message: "zero bytes is not a valid MerkleHashOrchard".to_string(),
})?;
[zero_hash; MERKLE_DEPTH]
};
let dummy_merkle_path = MerklePath::from_parts(0u32, dummy_auth_path);
let anchor = {
let cm = signed_note.commitment();
let root = dummy_merkle_path.root(cm.into());
Anchor::from(root)
};
let mut builder = Builder::new(BundleType::DEFAULT, anchor);
builder
.add_spend(fvk.clone(), signed_note, dummy_merkle_path)
.map_err(|e| VotingError::Internal {
message: format!("Builder::add_spend failed: {:?}", e),
})?;
let ovk = fvk.to_ovk(Scope::External);
let memo = {
let zec_whole = total_weight / 100_000_000;
let zec_frac = total_weight % 100_000_000;
let memo_str = format!(
"I am authorizing this hotkey managed by my wallet to vote on {} with {}.{:08} ZEC.",
round_name, zec_whole, zec_frac
);
let mut buf = [0u8; 512];
let bytes = memo_str.as_bytes();
let len = bytes.len().min(512);
buf[..len].copy_from_slice(&bytes[..len]);
buf
};
builder
.add_output(Some(ovk), hotkey_addr, NoteValue::from_raw(1), memo)
.map_err(|e| VotingError::Internal {
message: format!("Builder::add_output failed: {:?}", e),
})?;
let (mut orchard_pczt_bundle, bundle_meta) =
builder.build_for_pczt(&mut rng).map_err(|e| {
VotingError::Internal {
message: format!("Builder::build_for_pczt failed: {:?}", e),
}
})?;
let spend_idx = bundle_meta.spend_action_index(0).ok_or_else(|| {
VotingError::Internal {
message: "BundleMetadata missing spend action index".to_string(),
}
})?;
let output_idx = bundle_meta.output_action_index(0).ok_or_else(|| {
VotingError::Internal {
message: "BundleMetadata missing output action index".to_string(),
}
})?;
let spend_action = &orchard_pczt_bundle.actions()[spend_idx];
let nf_signed_bytes: [u8; 32] = spend_action.spend().nullifier().to_bytes();
let rk_bytes: [u8; 32] = spend_action.spend().rk().into();
let alpha = spend_action
.spend()
.alpha()
.ok_or_else(|| VotingError::Internal {
message: "PCZT spend missing alpha".to_string(),
})?;
let alpha_bytes: [u8; 32] = alpha.to_repr();
let rseed_signed_from_pczt = spend_action
.spend()
.rseed()
.ok_or_else(|| VotingError::Internal {
message: "PCZT spend missing rseed".to_string(),
})?;
if rseed_signed_from_pczt.as_bytes() != &rseed_signed_bytes {
return Err(VotingError::Internal {
message: "rseed mismatch between note and PCZT".to_string(),
});
}
let output_action = &orchard_pczt_bundle.actions()[output_idx];
let cmx_new_bytes: [u8; 32] = output_action.output().cmx().to_bytes();
let rseed_output = output_action
.output()
.rseed()
.ok_or_else(|| VotingError::Internal {
message: "PCZT output missing rseed".to_string(),
})?;
let rseed_output_bytes: [u8; 32] = *rseed_output.as_bytes();
let zip32_deriv = Zip32Derivation::parse(
*seed_fingerprint,
vec![
32 | (1 << 31), coin_type | (1 << 31), account_index | (1 << 31), ],
)
.map_err(|e| VotingError::Internal {
message: format!("Zip32Derivation::parse failed: {:?}", e),
})?;
orchard_pczt_bundle
.update_with(|mut updater| {
updater.update_action_with(spend_idx, |mut action_updater| {
action_updater.set_spend_zip32_derivation(zip32_deriv);
Ok(())
})
})
.map_err(|e| VotingError::Internal {
message: format!("PCZT updater failed: {:?}", e),
})?;
let branch_id = BranchId::try_from(consensus_branch_id).map_err(|e| {
VotingError::InvalidInput {
message: format!("invalid consensus_branch_id 0x{:08X}: {}", consensus_branch_id, e),
}
})?;
let network = match coin_type {
133 => Network::MainNetwork,
_ => Network::TestNetwork,
};
let parts = PcztParts {
params: network,
version: TxVersion::suggested_for_branch(branch_id),
consensus_branch_id: branch_id,
lock_time: 0,
expiry_height: BlockHeight::from_u32(0), transparent: None,
sapling: None,
orchard: Some(orchard_pczt_bundle),
};
let pczt = pczt::roles::creator::Creator::build_from_parts(parts).ok_or_else(|| {
VotingError::Internal {
message: "Creator::build_from_parts returned None (incompatible tx version)".to_string(),
}
})?;
let pczt = pczt::roles::io_finalizer::IoFinalizer::new(pczt)
.finalize_io()
.map_err(|e| VotingError::Internal {
message: format!("IoFinalizer::finalize_io failed: {:?}", e),
})?;
let pczt_bytes = pczt.serialize();
let pczt_sighash = extract_pczt_sighash(&pczt_bytes)?;
let action_bytes = encode_delegation_action_bytes(
&nf_signed_bytes,
&rk_bytes,
&cmx_new_bytes,
&van,
&gov_nullifiers,
&vri_32,
)?;
Ok(GovernancePczt {
pczt_bytes,
rk: rk_bytes.to_vec(),
alpha: alpha_bytes.to_vec(),
nf_signed: nf_signed_bytes.to_vec(),
cmx_new: cmx_new_bytes.to_vec(),
gov_nullifiers,
van,
van_comm_rand: van_comm_rand.to_vec(),
dummy_nullifiers,
rho_signed,
padded_cmx,
rseed_signed: rseed_signed_bytes.to_vec(),
rseed_output: rseed_output_bytes.to_vec(),
action_bytes,
action_index: spend_idx,
padded_note_secrets,
pczt_sighash: pczt_sighash.to_vec(),
})
}
pub fn extract_pczt_sighash(pczt_bytes: &[u8]) -> Result<[u8; 32], VotingError> {
let pczt = pczt::Pczt::parse(pczt_bytes).map_err(|e| VotingError::Internal {
message: format!("Failed to parse PCZT: {:?}", e),
})?;
let signer =
pczt::roles::signer::Signer::new(pczt).map_err(|e| VotingError::Internal {
message: format!("Failed to create Signer from PCZT: {:?}", e),
})?;
Ok(signer.shielded_sighash())
}
pub fn extract_spend_auth_sig(
signed_pczt_bytes: &[u8],
action_index: usize,
) -> Result<[u8; 64], VotingError> {
let pczt = pczt::Pczt::parse(signed_pczt_bytes).map_err(|e| VotingError::Internal {
message: format!("Failed to parse signed PCZT: {:?}", e),
})?;
let actions = pczt.orchard().actions();
if action_index < actions.len() {
if let Some(sig) = actions[action_index].spend().spend_auth_sig() {
return Ok(*sig);
}
}
for action in actions {
if let Some(sig) = action.spend().spend_auth_sig() {
return Ok(*sig);
}
}
Err(VotingError::Internal {
message: format!(
"No spend_auth_sig found in any of the {} actions in the signed PCZT",
actions.len()
),
})
}
#[cfg(test)]
mod tests {
use super::*;
use orchard::keys::SpendingKey;
fn mock_note() -> NoteInfo {
NoteInfo {
commitment: vec![0x01; 32],
nullifier: vec![0x02; 32],
value: 15_000_000,
position: 42,
diversifier: vec![0; 11],
rho: vec![0; 32],
rseed: vec![0; 32],
scope: 0,
ufvk_str: String::new(),
}
}
fn mock_params() -> VotingRoundParams {
VotingRoundParams {
vote_round_id: "0101010101010101010101010101010101010101010101010101010101010101"
.to_string(),
snapshot_height: 100_000,
ea_pk: vec![0xEA; 32],
nc_root: vec![0x01; 32],
nullifier_imt_root: vec![0x02; 32],
}
}
fn mock_fvk_bytes() -> Vec<u8> {
let sk = SpendingKey::from_bytes([0x42; 32]).expect("valid spending key");
let fvk = FullViewingKey::from(&sk);
fvk.to_bytes().to_vec()
}
fn mock_hotkey_address() -> Vec<u8> {
let sk = SpendingKey::from_bytes([0x43; 32]).expect("valid spending key");
let fvk = FullViewingKey::from(&sk);
let addr = fvk.address_at(0u32, Scope::External);
addr.to_raw_address_bytes().to_vec()
}
#[test]
fn test_action_bytes_canonical_encoding_order() {
let nf_signed = [0x01; 32];
let rk = [0x02; 32];
let cmx_new = [0x03; 32];
let van_comm = vec![0x04; 32];
let gov_nullifiers = vec![
vec![0x05; 32],
vec![0x06; 32],
vec![0x07; 32],
vec![0x08; 32],
vec![0x0A; 32],
];
let vote_round_id = [0x09; 32];
let encoded = encode_delegation_action_bytes(
&nf_signed,
&rk,
&cmx_new,
&van_comm,
&gov_nullifiers,
&vote_round_id,
)
.unwrap();
assert_eq!(encoded.len(), 32 * 10);
assert_eq!(&encoded[0..32], &nf_signed);
assert_eq!(&encoded[32..64], &rk);
assert_eq!(&encoded[64..96], &cmx_new);
assert_eq!(&encoded[96..128], &van_comm);
assert_eq!(&encoded[128..160], &gov_nullifiers[0]);
assert_eq!(&encoded[160..192], &gov_nullifiers[1]);
assert_eq!(&encoded[192..224], &gov_nullifiers[2]);
assert_eq!(&encoded[224..256], &gov_nullifiers[3]);
assert_eq!(&encoded[256..288], &gov_nullifiers[4]);
assert_eq!(&encoded[288..320], &vote_round_id);
}
#[test]
fn test_action_bytes_rejects_non_canonical_gov_nullifier_count() {
let encoded = encode_delegation_action_bytes(
&[0x01; 32],
&[0x02; 32],
&[0x03; 32],
&[0x04; 32],
&vec![vec![0x05; 32]; 4],
&[0x06; 32],
);
assert!(encoded.is_err());
}
const NU5_BRANCH_ID: u32 = 0xC2D6D0B4;
const MAINNET_COIN_TYPE: u32 = 133;
const MOCK_SEED_FP: [u8; 32] = [0xAA; 32];
const MOCK_ACCOUNT: u32 = 0;
#[test]
fn test_build_governance_pczt_one_note() {
let result = build_governance_pczt(
&[mock_note()],
&mock_params(),
&mock_fvk_bytes(),
&mock_hotkey_address(),
NU5_BRANCH_ID,
MAINNET_COIN_TYPE,
&MOCK_SEED_FP,
MOCK_ACCOUNT,
"Test Round",
)
.unwrap();
assert!(!result.pczt_bytes.is_empty());
let parsed = pczt::Pczt::parse(&result.pczt_bytes);
assert!(parsed.is_ok(), "PCZT bytes should parse: {:?}", parsed.err());
assert_eq!(result.rk.len(), 32);
assert_ne!(result.rk, vec![0u8; 32]);
assert_eq!(result.alpha.len(), 32);
assert_ne!(result.alpha, vec![0u8; 32]);
assert_eq!(result.nf_signed.len(), 32);
assert_ne!(result.nf_signed, vec![0u8; 32]);
assert_eq!(result.cmx_new.len(), 32);
assert_ne!(result.cmx_new, vec![0u8; 32]);
assert_eq!(result.gov_nullifiers.len(), 5);
for gn in &result.gov_nullifiers {
assert_eq!(gn.len(), 32);
}
assert_eq!(result.van.len(), 32);
assert_eq!(result.van_comm_rand.len(), 32);
assert_eq!(result.rho_signed.len(), 32);
assert_ne!(result.rho_signed, vec![0u8; 32]);
assert_eq!(result.padded_cmx.len(), 4);
assert_eq!(result.rseed_signed.len(), 32);
assert_ne!(result.rseed_signed, vec![0u8; 32]);
assert_eq!(result.rseed_output.len(), 32);
assert_ne!(result.rseed_output, vec![0u8; 32]);
assert_eq!(result.action_bytes.len(), 320);
assert!(result.action_index <= 1);
let pczt = parsed.unwrap();
assert_eq!(pczt.orchard().actions().len(), 2);
}
#[test]
fn test_build_governance_pczt_five_notes() {
let notes: Vec<NoteInfo> = (0..5)
.map(|i| NoteInfo {
commitment: vec![i as u8 + 1; 32],
nullifier: vec![i as u8 + 0x10; 32],
value: 13_000_000,
position: i as u64,
diversifier: vec![0; 11],
rho: vec![0; 32],
rseed: vec![0; 32],
scope: 0,
ufvk_str: String::new(),
})
.collect();
let result = build_governance_pczt(
¬es,
&mock_params(),
&mock_fvk_bytes(),
&mock_hotkey_address(),
NU5_BRANCH_ID,
MAINNET_COIN_TYPE,
&MOCK_SEED_FP,
MOCK_ACCOUNT,
"Test Round",
)
.unwrap();
assert_eq!(result.gov_nullifiers.len(), 5);
assert!(result.padded_cmx.is_empty());
assert!(result.dummy_nullifiers.is_empty());
for i in 0..5 {
for j in (i + 1)..5 {
assert_ne!(result.gov_nullifiers[i], result.gov_nullifiers[j]);
}
}
}
#[test]
fn test_build_governance_pczt_different_rk_each_call() {
let result1 = build_governance_pczt(
&[mock_note()],
&mock_params(),
&mock_fvk_bytes(),
&mock_hotkey_address(),
NU5_BRANCH_ID,
MAINNET_COIN_TYPE,
&MOCK_SEED_FP,
MOCK_ACCOUNT,
"Test Round",
)
.unwrap();
let result2 = build_governance_pczt(
&[mock_note()],
&mock_params(),
&mock_fvk_bytes(),
&mock_hotkey_address(),
NU5_BRANCH_ID,
MAINNET_COIN_TYPE,
&MOCK_SEED_FP,
MOCK_ACCOUNT,
"Test Round",
)
.unwrap();
assert_ne!(result1.rk, result2.rk);
assert_ne!(result1.alpha, result2.alpha);
}
}