pub use crate::phases::DelegationPhase;
use std::borrow::Borrow;
pub use crate::lwd::branch_id_for_height;
use crate::note_bundling::BundlePolicy;
pub use crate::selection::{
gather_delegation_wallet_inputs, DelegationWalletInputs, GatherDelegationWalletParams,
};
use crate::{
governance::BUNDLE_NOTE_SLOTS,
precompute::PirPrecomputeReport,
round::{BundleLayout, RoundParams, VotingDb},
types::{DelegationProgressReporter, Network, NoteInfo, VotingError, VotingHotkey},
};
use zcash_client_backend::data_api::{wallet::ConfirmationsPolicy, Account, WalletRead};
use zcash_client_sqlite::{AccountUuid, WalletDb};
use zcash_protocol::consensus::{NetworkConstants, Parameters};
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct DelegationKeys {
pub(crate) fvk_bytes: Vec<u8>,
pub(crate) hotkey_raw_address: [u8; 43],
pub(crate) seed_fingerprint: [u8; 32],
pub(crate) account_index: u32,
pub(crate) address_index: u32,
pub(crate) network: Network,
pub(crate) coin_type: u32,
pub(crate) round_name: String,
}
impl DelegationKeys {
#[allow(clippy::too_many_arguments)]
pub(crate) fn with_hotkey_bytes(
fvk_bytes: Vec<u8>,
hotkey_raw_address: &[u8],
seed_fingerprint: [u8; 32],
account_index: u32,
address_index: u32,
network: Network,
round_name: String,
) -> Result<Self, VotingError> {
Ok(Self {
fvk_bytes,
hotkey_raw_address: array43("hotkey_raw_address", hotkey_raw_address)?,
seed_fingerprint,
account_index,
address_index,
network,
coin_type: network.network_type().coin_type(),
round_name,
})
}
#[allow(clippy::too_many_arguments)]
pub fn with_voting_hotkey(
fvk_bytes: Vec<u8>,
hotkey: &VotingHotkey,
seed_fingerprint: [u8; 32],
account_index: u32,
round_name: String,
) -> Result<Self, VotingError> {
Self::with_hotkey_bytes(
fvk_bytes,
hotkey.raw_orchard_address(),
seed_fingerprint,
account_index,
hotkey.address_index(),
hotkey.network(),
round_name,
)
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[non_exhaustive]
pub enum DelegationProgress {
SelectingNotes,
PcztBuilding,
PcztBuilt,
ProofStarting,
ProofProgress(f64),
ProofComplete,
SigningPayload,
PayloadReady,
}
#[deprecated(note = "use DelegationProgress")]
pub type DelegationStage = DelegationProgress;
pub trait BranchIdProvider {
fn consensus_branch_id(&self) -> Result<u32, VotingError>;
}
#[derive(Clone, Debug)]
pub struct LightwalletdBranchIdProvider {
resolved: u32,
}
impl LightwalletdBranchIdProvider {
pub async fn resolve(lightwalletd_url: &str, network: Network) -> Result<Self, VotingError> {
let branch_height = crate::lwd::latest_block_height_with_retry(lightwalletd_url).await?;
let resolved = crate::lwd::branch_id_for_height(network, branch_height)?;
Ok(Self { resolved })
}
pub fn resolved(consensus_branch_id: u32) -> Self {
Self {
resolved: consensus_branch_id,
}
}
}
impl BranchIdProvider for LightwalletdBranchIdProvider {
fn consensus_branch_id(&self) -> Result<u32, VotingError> {
Ok(self.resolved)
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DelegationAccountKeys {
pub account_index: u32,
pub orchard_fvk_bytes: [u8; 96],
pub seed_fingerprint: [u8; 32],
}
pub struct DelegationBundleContext {
pub voting_db: VotingDb,
pub round_id: String,
pub bundle_index: u32,
pub bundle_setup: BundleLayout,
pub selected_weight_zatoshi: u64,
pub bundle_note_infos: Vec<NoteInfo>,
pub delegated_weight_zatoshi: u64,
pub delegation_keys: DelegationKeys,
pub branch_id_provider: LightwalletdBranchIdProvider,
pub round_name: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DelegationRoundContext {
pub snapshot_height: u64,
pub round_name: String,
}
pub fn ensure_round_context(
voting_db: &VotingDb,
params: &RoundParams,
round_name: &str,
session_json: Option<&str>,
) -> Result<DelegationRoundContext, VotingError> {
let state = voting_db.ensure_round_state(params, session_json)?;
Ok(DelegationRoundContext {
snapshot_height: state.snapshot_height,
round_name: crate::round::delegation_round_name(params, round_name),
})
}
pub struct ResolveDelegationLwdParams<'a> {
pub lightwalletd_url: &'a str,
pub network: Network,
pub round_params: crate::VotingRoundParams,
pub round_name: &'a str,
}
#[derive(Clone, Debug)]
pub struct DelegationLwdInputs {
pub round_params: crate::VotingRoundParams,
pub resolved_round_name: String,
pub anchor_tree_state_bytes: Vec<u8>,
pub branch_id_provider: LightwalletdBranchIdProvider,
}
pub struct PrepareDelegationBundleParams<'a> {
pub lwd: DelegationLwdInputs,
pub session_json: Option<&'a str>,
pub account_uuid: &'a str,
pub voting_hotkey: &'a VotingHotkey,
pub bundle_index: u32,
pub bundle_policy: BundlePolicy,
}
#[derive(Clone, Debug)]
pub struct PreparedDelegationBundle {
pub round_id: String,
pub round_params: crate::VotingRoundParams,
pub bundle_index: u32,
pub layout: BundleLayout,
pub bundle_note_infos: Vec<NoteInfo>,
pub delegation_keys: DelegationKeys,
pub branch_id_provider: LightwalletdBranchIdProvider,
pub anchor_tree_state_bytes: Vec<u8>,
pub network: Network,
pub round_name: String,
}
pub async fn gather_delegation_lwd_inputs(
params: ResolveDelegationLwdParams<'_>,
) -> Result<DelegationLwdInputs, VotingError> {
crate::validate_round_params(¶ms.round_params)?;
let resolved_round_name =
crate::round::delegation_round_name(¶ms.round_params, params.round_name);
let anchor_tree_state_bytes = crate::lwd::anchor_tree_state_bytes_with_retry(
params.lightwalletd_url,
params.round_params.snapshot_height,
)
.await?;
let branch_id_provider =
LightwalletdBranchIdProvider::resolve(params.lightwalletd_url, params.network).await?;
Ok(DelegationLwdInputs {
round_params: params.round_params,
resolved_round_name,
anchor_tree_state_bytes,
branch_id_provider,
})
}
pub fn prepare_delegation_bundle<C, P, CL, R>(
voting_db: &VotingDb,
wallet_db: &WalletDb<C, P, CL, R>,
params: PrepareDelegationBundleParams<'_>,
) -> Result<PreparedDelegationBundle, VotingError>
where
C: Borrow<rusqlite::Connection>,
P: Parameters,
{
let lwd = params.lwd;
let session_json = params.session_json;
ensure_round_context(
voting_db,
&lwd.round_params,
&lwd.resolved_round_name,
session_json,
)?;
let DelegationLwdInputs {
round_params,
resolved_round_name,
anchor_tree_state_bytes,
branch_id_provider,
} = lwd;
let round_id = round_params.vote_round_id.clone();
let round_name = resolved_round_name.clone();
let scanned_height = match wallet_db
.get_wallet_summary(ConfirmationsPolicy::default())
.map_err(|e| VotingError::Internal {
message: format!("wallet summary lookup failed: {e}"),
})? {
Some(summary) => u32::from(summary.fully_scanned_height()) as u64,
None => 0,
};
let wallet_inputs = gather_delegation_wallet_inputs(GatherDelegationWalletParams {
wallet_db,
account_uuid: params.account_uuid,
voting_hotkey: params.voting_hotkey,
snapshot_height: round_params.snapshot_height,
scanned_height,
anchor_tree_state_bytes,
resolved_round_name,
})?;
voting_db.ensure_round(&round_params, None)?;
let layout = voting_db.ensure_bundles_with_skipped_suffix_with_policy(
&round_id,
&wallet_inputs.round_note_infos,
params.bundle_policy,
)?;
let bundle_note_infos = crate::round::bundle_notes_for_index_with_policy(
&wallet_inputs.round_note_infos,
&layout,
params.bundle_index,
params.bundle_policy,
)?;
let prepared = PreparedDelegationBundle {
round_id,
round_params,
bundle_index: params.bundle_index,
layout,
bundle_note_infos,
delegation_keys: wallet_inputs.delegation_keys,
branch_id_provider,
anchor_tree_state_bytes: wallet_inputs.anchor_tree_state_bytes,
network: params.voting_hotkey.network(),
round_name,
};
prepared.ensure_witnesses(voting_db, wallet_db)?;
Ok(prepared)
}
#[derive(Clone, Debug)]
pub struct DelegationInputs {
pub account_uuid: String,
pub round_params: crate::VotingRoundParams,
pub anchor_tree_state_bytes: Vec<u8>,
pub round_note_infos: Vec<NoteInfo>,
pub branch_id_provider: LightwalletdBranchIdProvider,
pub delegation_keys: DelegationKeys,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DelegationSetup {
pub pczt_bytes: Vec<u8>,
pub pczt_sighash: [u8; 32],
pub rk: [u8; 32],
pub action_index: usize,
pub action_bytes: Vec<u8>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct DelegationSigningRequest {
pub account_index: u32,
pub network: Network,
pub seed_fingerprint: [u8; 32],
pub sighash: [u8; 32],
pub alpha: [u8; 32],
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DelegationProof {
pub bytes: Vec<u8>,
pub rk: [u8; 32],
pub nf_signed: [u8; 32],
pub cmx_new: [u8; 32],
pub van_comm: [u8; 32],
pub gov_nullifiers: [[u8; 32]; BUNDLE_NOTE_SLOTS],
}
pub enum DelegationSigner {
Signature { sig: [u8; 64], sighash: [u8; 32] },
}
impl DelegationSigner {
pub fn signature_from_bytes(sig: &[u8], sighash: &[u8]) -> Result<Self, VotingError> {
Ok(Self::Signature {
sig: array64_slice("signature", sig)?,
sighash: array32_slice("sighash", sighash)?,
})
}
pub fn signature(sig: [u8; 64], sighash: [u8; 32]) -> Self {
Self::Signature { sig, sighash }
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PreparedSigner {
Signature { sig: [u8; 64], sighash: [u8; 32] },
}
impl PreparedSigner {
pub fn signature(sig: [u8; 64], sighash: [u8; 32]) -> Self {
Self::Signature { sig, sighash }
}
pub fn signature_from_bytes(sig: &[u8], sighash: &[u8]) -> Result<Self, VotingError> {
Ok(Self::Signature {
sig: array64_slice("signature", sig)?,
sighash: array32_slice("sighash", sighash)?,
})
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct DelegationSubmission {
pub proof: Vec<u8>,
pub rk: [u8; 32],
pub nf_signed: [u8; 32],
pub cmx_new: [u8; 32],
pub gov_comm: [u8; 32],
pub gov_nullifiers: [[u8; 32]; BUNDLE_NOTE_SLOTS],
pub alpha: [u8; 32],
pub vote_round_id: String,
pub spend_auth_sig: [u8; 64],
pub sighash: [u8; 32],
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SignedDelegationBundle {
pub submission: DelegationSubmission,
pub pczt_bytes: Vec<u8>,
pub eligible_weight_zatoshi: u64,
pub delegated_weight_zatoshi: u64,
pub bundle_count: u32,
pub bundle_index: u32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeystoneSigningRequest {
pub pczt_bytes: Vec<u8>,
pub redacted_pczt_bytes: Vec<u8>,
pub pczt_sighash: Vec<u8>,
pub rk: Vec<u8>,
pub action_index: u32,
pub display_memo: String,
pub eligible_weight_zatoshi: u64,
pub delegated_weight_zatoshi: u64,
pub bundle_count: u32,
pub bundle_index: u32,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PreparedDelegationReport {
pub report: PirPrecomputeReport,
pub layout: BundleLayout,
pub bundle_index: u32,
}
impl PreparedDelegationBundle {
pub fn delegated_weight_zatoshi(&self) -> Result<u64, VotingError> {
crate::round::quantized_bundle_weight(&self.bundle_note_infos)
}
pub fn eligible_weight_zatoshi(&self) -> u64 {
self.layout.eligible_weight
}
pub fn ensure_witnesses<C, P, CL, R>(
&self,
voting_db: &VotingDb,
wallet_db: &WalletDb<C, P, CL, R>,
) -> Result<(), VotingError>
where
C: Borrow<rusqlite::Connection>,
P: Parameters,
{
if !voting_db.has_complete_witnesses(
&self.round_id,
self.bundle_index,
&self.bundle_note_infos,
)? {
crate::precompute::note_witnesses(
voting_db,
&self.round_id,
self.bundle_index,
&self.anchor_tree_state_bytes,
&self.bundle_note_infos,
wallet_db,
)?;
}
Ok(())
}
pub fn precompute<C, P, CL, R>(
&self,
voting_db: &VotingDb,
wallet_db: &WalletDb<C, P, CL, R>,
pir_client: &pir_client::PirClientBlocking,
) -> Result<PreparedDelegationReport, VotingError>
where
C: Borrow<rusqlite::Connection>,
P: Parameters,
{
self.ensure_witnesses(voting_db, wallet_db)?;
crate::precompute::warm_delegation_pir(
voting_db,
&self.round_id,
self.bundle_index,
&self.bundle_note_infos,
self.layout.clone(),
pir_client,
self.network,
)
}
pub fn setup(
&self,
voting_db: &VotingDb,
stages: &dyn DelegationProgressReporter,
) -> Result<DelegationSetup, VotingError> {
crate::delegate::setup(
voting_db,
&self.round_id,
self.bundle_index,
&self.bundle_note_infos,
&self.delegation_keys,
&self.branch_id_provider,
stages,
)
}
pub fn prove(
&self,
voting_db: &VotingDb,
pir_client: &pir_client::PirClientBlocking,
stages: &dyn DelegationProgressReporter,
) -> Result<DelegationProof, VotingError> {
crate::delegate::prove(
voting_db,
&self.round_id,
self.bundle_index,
&self.bundle_note_infos,
&self.delegation_keys,
pir_client,
stages,
)
}
pub fn submission(
&self,
voting_db: &VotingDb,
signer: PreparedSigner,
) -> Result<DelegationSubmission, VotingError> {
let signer = match signer {
PreparedSigner::Signature { sig, sighash } => DelegationSigner::signature(sig, sighash),
};
crate::delegate::submission(voting_db, &self.round_id, self.bundle_index, signer)
}
pub fn signed_bundle(
&self,
voting_db: &VotingDb,
pczt_bytes: Vec<u8>,
signer: PreparedSigner,
) -> Result<SignedDelegationBundle, VotingError> {
if !pczt_bytes.is_empty() {
let provided_sighash = pczt_sighash(&pczt_bytes)?;
let PreparedSigner::Signature { sighash, .. } = &signer;
if provided_sighash != *sighash {
return Err(VotingError::InvalidInput {
message: "pczt_bytes sighash does not match delegation signer sighash"
.to_string(),
});
}
}
let submission = self.submission(voting_db, signer)?;
Ok(SignedDelegationBundle {
submission,
pczt_bytes,
eligible_weight_zatoshi: self.eligible_weight_zatoshi(),
delegated_weight_zatoshi: self.delegated_weight_zatoshi()?,
bundle_count: self.layout.bundle_count,
bundle_index: self.bundle_index,
})
}
pub fn signing_request(
&self,
voting_db: &VotingDb,
) -> Result<DelegationSigningRequest, VotingError> {
crate::delegate::signing_request(
voting_db,
&self.round_id,
self.bundle_index,
&self.delegation_keys,
)
}
pub fn keystone_request(
&self,
voting_db: &VotingDb,
stages: &dyn DelegationProgressReporter,
) -> Result<KeystoneSigningRequest, VotingError> {
let setup = self.setup(voting_db, stages)?;
let redacted_pczt_bytes = redact_delegation_pczt_for_signer(&setup.pczt_bytes)?;
let display_weight_zatoshi = crate::round::raw_bundle_weight(&self.bundle_note_infos)?;
let display_memo = display_memo(&self.round_name, display_weight_zatoshi);
let action_index = crate::wire::BoundedU32::try_from(setup.action_index).map_err(|_| {
VotingError::InvalidInput {
message: format!("action_index {} does not fit u32", setup.action_index),
}
})?;
Ok(KeystoneSigningRequest {
pczt_bytes: setup.pczt_bytes,
redacted_pczt_bytes,
pczt_sighash: setup.pczt_sighash.to_vec(),
rk: setup.rk.to_vec(),
action_index: action_index.0,
display_memo,
eligible_weight_zatoshi: self.eligible_weight_zatoshi(),
delegated_weight_zatoshi: self.delegated_weight_zatoshi()?,
bundle_count: self.layout.bundle_count,
bundle_index: self.bundle_index,
})
}
}
pub fn load_account_keys<C, P, CL, R>(
db: &WalletDb<C, P, CL, R>,
account_uuid: &str,
) -> Result<DelegationAccountKeys, VotingError>
where
C: Borrow<rusqlite::Connection>,
P: Parameters,
{
let account_uuid = parse_account_uuid(account_uuid)?;
let account = db
.get_account(account_uuid)
.map_err(|e| VotingError::Internal {
message: format!("failed to load voting account: {e}"),
})?
.ok_or_else(|| VotingError::InvalidInput {
message: "voting account not found".to_string(),
})?;
let derivation =
account
.source()
.key_derivation()
.ok_or_else(|| VotingError::InvalidInput {
message: "voting account has no ZIP-32 derivation metadata".to_string(),
})?;
let ufvk = account.ufvk().ok_or_else(|| VotingError::InvalidInput {
message: "voting account has no UFVK".to_string(),
})?;
let orchard_fvk = ufvk.orchard().ok_or_else(|| VotingError::InvalidInput {
message: "voting account has no Orchard viewing key".to_string(),
})?;
Ok(DelegationAccountKeys {
account_index: u32::from(derivation.account_index()),
orchard_fvk_bytes: orchard_fvk.to_bytes(),
seed_fingerprint: derivation.seed_fingerprint().to_bytes(),
})
}
fn parse_account_uuid(account_uuid: &str) -> Result<AccountUuid, VotingError> {
let uuid = uuid::Uuid::parse_str(account_uuid).map_err(|e| VotingError::InvalidInput {
message: format!("invalid account UUID: {e}"),
})?;
Ok(AccountUuid::from_uuid(uuid))
}
pub fn setup(
db: &VotingDb,
round_id: &str,
bundle_index: u32,
notes: &[NoteInfo],
keys: &DelegationKeys,
branch_id_provider: &dyn BranchIdProvider,
stages: &dyn DelegationProgressReporter,
) -> Result<DelegationSetup, VotingError> {
let consensus_branch_id = branch_id_provider.consensus_branch_id()?;
stages.on_progress(DelegationProgress::PcztBuilding);
let pczt =
db.build_governance_pczt(round_id, bundle_index, notes, keys, consensus_branch_id)?;
stages.on_progress(DelegationProgress::PcztBuilt);
let pczt_sighash = array32("pczt_sighash", pczt.pczt_sighash)?;
Ok(DelegationSetup {
pczt_bytes: pczt.pczt_bytes,
pczt_sighash,
rk: array32("rk", pczt.rk)?,
action_index: pczt.action_index,
action_bytes: pczt.action_bytes,
})
}
pub fn signing_request(
db: &VotingDb,
round_id: &str,
bundle_index: u32,
keys: &DelegationKeys,
) -> Result<DelegationSigningRequest, VotingError> {
db.get_delegation_signing_request(round_id, bundle_index, keys)
}
pub fn prove(
db: &VotingDb,
round_id: &str,
bundle_index: u32,
notes: &[NoteInfo],
keys: &DelegationKeys,
pir_client: &pir_client::PirClientBlocking,
stages: &dyn DelegationProgressReporter,
) -> Result<DelegationProof, VotingError> {
stages.on_progress(DelegationProgress::ProofStarting);
let proof =
db.build_and_prove_delegation(round_id, bundle_index, notes, keys, pir_client, stages)?;
stages.on_progress(DelegationProgress::ProofComplete);
Ok(DelegationProof {
bytes: proof.proof,
rk: array32("rk", proof.rk)?,
nf_signed: array32("nf_signed", proof.nf_signed)?,
cmx_new: array32("cmx_new", proof.cmx_new)?,
van_comm: array32("van_comm", proof.van_comm)?,
gov_nullifiers: array32x_bundle_note_slots("gov_nullifiers", proof.gov_nullifiers)?,
})
}
pub fn submission(
db: &VotingDb,
round_id: &str,
bundle_index: u32,
signer: DelegationSigner,
) -> Result<DelegationSubmission, VotingError> {
let data = match signer {
DelegationSigner::Signature { sig, sighash } => {
db.get_delegation_submission_with_signature(round_id, bundle_index, &sig, &sighash)
}
}?;
Ok(DelegationSubmission {
proof: data.proof,
rk: array32("rk", data.rk)?,
nf_signed: array32("nf_signed", data.nf_signed)?,
cmx_new: array32("cmx_new", data.cmx_new)?,
gov_comm: array32("gov_comm", data.gov_comm)?,
gov_nullifiers: array32x_bundle_note_slots("gov_nullifiers", data.gov_nullifiers)?,
alpha: array32("alpha", data.alpha)?,
vote_round_id: data.vote_round_id,
spend_auth_sig: array64("spend_auth_sig", data.spend_auth_sig)?,
sighash: array32("sighash", data.sighash)?,
})
}
pub fn record_submission(
db: &VotingDb,
round_id: &str,
bundle_index: u32,
tx_hash: &str,
) -> Result<(), VotingError> {
db.store_delegation_tx_hash(round_id, bundle_index, tx_hash)
}
pub fn record_van_position(
db: &VotingDb,
round_id: &str,
bundle_index: u32,
position: u32,
) -> Result<(), VotingError> {
db.store_van_position(round_id, bundle_index, position)
}
pub fn pczt_sighash(pczt_bytes: &[u8]) -> Result<[u8; 32], VotingError> {
crate::action::extract_pczt_sighash(pczt_bytes)
}
pub fn spend_auth_signature(
signed_pczt_bytes: &[u8],
action_index: usize,
) -> Result<[u8; 64], VotingError> {
crate::action::extract_spend_auth_sig(signed_pczt_bytes, action_index)
}
fn redact_delegation_pczt_for_signer(pczt_bytes: &[u8]) -> Result<Vec<u8>, VotingError> {
use pczt::roles::redactor::Redactor;
let pczt = pczt::Pczt::parse(pczt_bytes).map_err(|e| VotingError::InvalidInput {
message: format!("parse PCZT failed: {e:?}"),
})?;
let redacted = Redactor::new(pczt)
.redact_global_with(|mut r| r.redact_proprietary("zcash_client_backend:proposal_info"))
.redact_orchard_with(|mut r| {
r.redact_actions(|mut ar| {
ar.clear_spend_witness();
ar.redact_output_proprietary("zcash_client_backend:output_info");
});
})
.redact_sapling_with(|mut r| {
r.redact_spends(|mut sr| sr.clear_witness());
r.redact_outputs(|mut or| {
or.redact_proprietary("zcash_client_backend:output_info");
});
})
.redact_transparent_with(|mut r| {
r.redact_outputs(|mut or| {
or.redact_proprietary("zcash_client_backend:output_info");
});
})
.finish();
Ok(redacted.serialize())
}
pub fn display_memo(round_name: &str, total_weight_zatoshi: u64) -> String {
const DISPLAY_MEMO_MAX_BYTES: usize = 512;
const DISPLAY_MEMO_PREFIX: &str =
"I am authorizing this hotkey managed by my wallet to vote on ";
const DISPLAY_MEMO_ROUND_SUFFIX: &str = ".\nAmount:";
let zec_whole = total_weight_zatoshi / 100_000_000;
let zec_frac = total_weight_zatoshi % 100_000_000;
let amount_suffix = format!(" {}.{:08} ZEC.", zec_whole, zec_frac);
let round_name_budget = DISPLAY_MEMO_MAX_BYTES
.saturating_sub(
DISPLAY_MEMO_PREFIX.len() + DISPLAY_MEMO_ROUND_SUFFIX.len() + amount_suffix.len(),
);
let round_name_visible = truncate_utf8_prefix(round_name, round_name_budget);
let memo = format!(
"{}{}{}{}",
DISPLAY_MEMO_PREFIX, round_name_visible, DISPLAY_MEMO_ROUND_SUFFIX, amount_suffix
);
debug_assert!(memo.len() <= DISPLAY_MEMO_MAX_BYTES);
memo
}
fn truncate_utf8_prefix(value: &str, max_bytes: usize) -> &str {
if value.len() <= max_bytes {
return value;
}
let mut end = max_bytes;
while !value.is_char_boundary(end) {
end = end.saturating_sub(1);
}
&value[..end]
}
fn array32(label: &str, value: Vec<u8>) -> Result<[u8; 32], VotingError> {
value
.try_into()
.map_err(|value: Vec<u8>| VotingError::Internal {
message: format!("{label} must be 32 bytes, got {}", value.len()),
})
}
fn array64(label: &str, value: Vec<u8>) -> Result<[u8; 64], VotingError> {
value
.try_into()
.map_err(|value: Vec<u8>| VotingError::Internal {
message: format!("{label} must be 64 bytes, got {}", value.len()),
})
}
fn array32x_bundle_note_slots(
label: &str,
values: Vec<Vec<u8>>,
) -> Result<[[u8; 32]; BUNDLE_NOTE_SLOTS], VotingError> {
if values.len() != BUNDLE_NOTE_SLOTS {
return Err(VotingError::Internal {
message: format!(
"{label} must contain {BUNDLE_NOTE_SLOTS} entries, got {}",
values.len()
),
});
}
let arrays = values
.into_iter()
.enumerate()
.map(|(idx, value)| array32(&format!("{label}[{idx}]"), value))
.collect::<Result<Vec<_>, _>>()?;
arrays
.try_into()
.map_err(|arrays: Vec<[u8; 32]>| VotingError::Internal {
message: format!(
"{label} must contain {BUNDLE_NOTE_SLOTS} entries, got {}",
arrays.len()
),
})
}
fn array43(label: &str, value: &[u8]) -> Result<[u8; 43], VotingError> {
value.try_into().map_err(|_| VotingError::InvalidInput {
message: format!("{label} must be 43 bytes, got {}", value.len()),
})
}
fn array32_slice(label: &str, value: &[u8]) -> Result<[u8; 32], VotingError> {
value.try_into().map_err(|_| VotingError::InvalidInput {
message: format!("{label} must be 32 bytes, got {}", value.len()),
})
}
fn array64_slice(label: &str, value: &[u8]) -> Result<[u8; 64], VotingError> {
value.try_into().map_err(|_| VotingError::InvalidInput {
message: format!("{label} must be 64 bytes, got {}", value.len()),
})
}
#[cfg(test)]
mod tests {
use super::*;
use orchard::{
note::{RandomSeed, Rho},
value::NoteValue,
};
use rusqlite::{params, Connection};
use secrecy::{ExposeSecret, SecretVec};
use zcash_client_backend::data_api::{chain::ChainState, AccountBirthday, WalletWrite};
use zcash_client_sqlite::{util::SystemClock, wallet::init::init_wallet_db};
use zcash_primitives::block::BlockHash;
use zcash_protocol::consensus::{
Network as ZcashNetwork, NetworkConstants, NetworkUpgrade, Parameters,
};
use zip32::Scope;
fn test_voting_hotkey() -> VotingHotkey {
VotingHotkey::from_stored_secret(&[0x77; 64], Network::Testnet).unwrap()
}
#[test]
fn ensure_round_context_initializes_round_and_resolves_round_name() {
let voting_db = VotingDb::open_in_memory().unwrap();
voting_db.set_wallet_id("ensure-round-context");
let params = crate::VotingRoundParams {
vote_round_id: "0101010101010101010101010101010101010101010101010101010101010101"
.to_string(),
snapshot_height: 42,
ea_pk: vec![1; 32],
nc_root: vec![2; 32],
nullifier_imt_root: vec![3; 32],
};
let named = ensure_round_context(&voting_db, ¶ms, "Demo Round", Some("{}")).unwrap();
let fallback = ensure_round_context(&voting_db, ¶ms, "", None).unwrap();
assert_eq!(named.snapshot_height, 42);
assert_eq!(named.round_name, "Demo Round");
assert_eq!(fallback.round_name, params.vote_round_id);
assert_eq!(voting_db.list_rounds().unwrap().len(), 1);
}
#[test]
fn prepare_delegation_bundle_returns_plain_reusable_bundle_state() {
let (_voting_db, round_params, hotkey, prepared) = prepared_wallet_delegation_fixture();
let divisor = crate::governance::BALLOT_DIVISOR;
assert_eq!(prepared.round_id, round_params.vote_round_id);
assert_eq!(prepared.round_params.snapshot_height, 12);
assert_eq!(prepared.bundle_index, 0);
assert_eq!(prepared.layout.bundle_count, 1);
assert_eq!(prepared.layout.eligible_weight, divisor * 3);
assert_eq!(prepared.bundle_note_infos.len(), 2);
assert_eq!(prepared.bundle_note_infos[0].position, 3);
assert_eq!(prepared.bundle_note_infos[1].position, 7);
assert_eq!(prepared.anchor_tree_state_bytes, vec![0xAA, 0xBB]);
assert_eq!(prepared.network, Network::Testnet);
assert_eq!(prepared.round_name, "Demo Round");
assert_eq!(
prepared.branch_id_provider.consensus_branch_id().unwrap(),
0x4DEC_4DF0
);
assert_eq!(
&prepared.delegation_keys.hotkey_raw_address,
hotkey.raw_orchard_address()
);
}
#[test]
fn signed_bundle_rejects_pczt_sighash_mismatch() {
let (voting_db, _round_params, _hotkey, prepared) = prepared_wallet_delegation_fixture();
let setup = prepared
.setup(&voting_db, &crate::types::NoopProgressReporter)
.unwrap();
let mut wrong_sighash = setup.pczt_sighash;
wrong_sighash[0] ^= 0xFF;
let err = prepared
.signed_bundle(
&voting_db,
setup.pczt_bytes,
PreparedSigner::signature([0; 64], wrong_sighash),
)
.unwrap_err()
.to_string();
assert!(err.contains("pczt_bytes sighash does not match delegation signer sighash"));
}
#[test]
fn prepared_bundle_metadata_uses_quantized_weight() {
let divisor = crate::governance::BALLOT_DIVISOR;
let prepared = prepared_bundle_fixture(vec![note_info(0, divisor + 23)]);
assert_eq!(prepared.delegated_weight_zatoshi().unwrap(), divisor);
assert_eq!(
crate::round::raw_bundle_weight(&prepared.bundle_note_infos).unwrap(),
divisor + 23
);
}
fn setup_test_account(
conn: &mut Connection,
) -> (
zcash_client_sqlite::AccountUuid,
orchard::keys::FullViewingKey,
) {
let seed = SecretVec::new(vec![7u8; 32]);
let mut db = WalletDb::from_connection(
conn,
ZcashNetwork::TestNetwork,
SystemClock,
rand::rngs::OsRng,
);
init_wallet_db(&mut db, Some(SecretVec::new(seed.expose_secret().to_vec()))).unwrap();
let sapling_height = ZcashNetwork::TestNetwork
.activation_height(NetworkUpgrade::Sapling)
.expect("testnet has Sapling activation");
let birthday = AccountBirthday::from_parts(
ChainState::empty(sapling_height - 1, BlockHash([0; 32])),
None,
);
let (account_uuid, usk) = db.create_account("voter", &seed, &birthday, None).unwrap();
let orchard_fvk = usk
.to_unified_full_viewing_key()
.orchard()
.expect("test account has Orchard viewing key")
.clone();
(account_uuid, orchard_fvk)
}
fn account_internal_id(
conn: &Connection,
account_uuid: &zcash_client_sqlite::AccountUuid,
) -> i64 {
conn.query_row(
"SELECT id FROM accounts WHERE uuid = ?1",
params![account_uuid.expose_uuid().as_bytes()],
|row| row.get(0),
)
.unwrap()
}
fn prepared_bundle_fixture(bundle_note_infos: Vec<NoteInfo>) -> PreparedDelegationBundle {
PreparedDelegationBundle {
round_id: "round-1".to_string(),
round_params: crate::VotingRoundParams {
vote_round_id: "round-1".to_string(),
snapshot_height: 42,
ea_pk: vec![1; 32],
nc_root: vec![2; 32],
nullifier_imt_root: vec![3; 32],
},
bundle_index: 0,
layout: BundleLayout {
bundle_count: 1,
eligible_weight: crate::round::quantized_bundle_weight(&bundle_note_infos).unwrap(),
dropped_count: 0,
},
bundle_note_infos,
delegation_keys: DelegationKeys {
fvk_bytes: vec![0; 96],
hotkey_raw_address: [0; 43],
seed_fingerprint: [0; 32],
account_index: 0,
address_index: 0,
network: Network::Testnet,
coin_type: Network::Testnet.network_type().coin_type(),
round_name: "Demo Round".to_string(),
},
branch_id_provider: LightwalletdBranchIdProvider::resolved(0x4DEC_4DF0),
anchor_tree_state_bytes: vec![0xAA],
network: Network::Testnet,
round_name: "Demo Round".to_string(),
}
}
fn prepared_wallet_delegation_fixture() -> (
VotingDb,
crate::VotingRoundParams,
VotingHotkey,
PreparedDelegationBundle,
) {
let mut conn = Connection::open_in_memory().unwrap();
let (account_uuid, orchard_fvk) = setup_test_account(&mut conn);
let account_ref = account_internal_id(&conn, &account_uuid);
let divisor = crate::governance::BALLOT_DIVISOR;
insert_orchard_note(&conn, account_ref, &orchard_fvk, 1, 10, divisor, 7);
insert_orchard_note(&conn, account_ref, &orchard_fvk, 2, 10, divisor * 2, 3);
insert_orchard_note(&conn, account_ref, &orchard_fvk, 3, 16, divisor * 3, 11);
let wallet_db = WalletDb::from_connection(
&conn,
ZcashNetwork::TestNetwork,
SystemClock,
rand::rngs::OsRng,
);
let voting_db = VotingDb::open_in_memory().unwrap();
voting_db.set_wallet_id("prepare-delegation-bundle-test");
let hotkey = test_voting_hotkey();
use group::GroupEncoding;
let ea_pk = pasta_curves::pallas::Point::from(voting_circuits::spend_auth_g_affine());
let round_params = crate::VotingRoundParams {
vote_round_id: "0101010101010101010101010101010101010101010101010101010101010101"
.to_string(),
snapshot_height: 12,
ea_pk: ea_pk.to_bytes().to_vec(),
nc_root: vec![2; 32],
nullifier_imt_root: vec![3; 32],
};
let lwd = DelegationLwdInputs {
round_params: round_params.clone(),
resolved_round_name: "Demo Round".to_string(),
anchor_tree_state_bytes: vec![0xAA, 0xBB],
branch_id_provider: LightwalletdBranchIdProvider::resolved(0x4DEC_4DF0),
};
let wallet_inputs = gather_delegation_wallet_inputs(GatherDelegationWalletParams {
wallet_db: &wallet_db,
account_uuid: &account_uuid.expose_uuid().to_string(),
voting_hotkey: &hotkey,
snapshot_height: round_params.snapshot_height,
scanned_height: 12,
anchor_tree_state_bytes: lwd.anchor_tree_state_bytes.clone(),
resolved_round_name: lwd.resolved_round_name.clone(),
})
.unwrap();
voting_db.ensure_round(&round_params, None).unwrap();
let layout = voting_db
.ensure_bundles_with_skipped_suffix_with_policy(
round_params.vote_round_id.as_str(),
&wallet_inputs.round_note_infos,
crate::BundlePolicy::default(),
)
.unwrap();
let bundle_note_infos = crate::round::bundle_notes_for_index_with_policy(
&wallet_inputs.round_note_infos,
&layout,
0,
crate::BundlePolicy::default(),
)
.unwrap();
let prepared = PreparedDelegationBundle {
round_id: round_params.vote_round_id.clone(),
round_params: lwd.round_params,
bundle_index: 0,
layout,
bundle_note_infos,
delegation_keys: wallet_inputs.delegation_keys,
branch_id_provider: lwd.branch_id_provider,
anchor_tree_state_bytes: wallet_inputs.anchor_tree_state_bytes,
network: hotkey.network(),
round_name: lwd.resolved_round_name,
};
(voting_db, round_params, hotkey, prepared)
}
fn note_info(position: u64, value: u64) -> NoteInfo {
NoteInfo {
commitment: vec![position as u8; 32],
nullifier: vec![position as u8 + 1; 32],
value,
position,
diversifier: vec![0x03; 11],
rho: vec![0x04; 32],
rseed: vec![0x05; 32],
scope: 0,
ufvk_str: "uview1test".to_string(),
}
}
fn insert_transaction(conn: &Connection, txid_tag: u8, mined_height: u32) -> i64 {
let txid = [txid_tag; 32];
conn.execute(
"INSERT INTO transactions (txid, mined_height, min_observed_height)
VALUES (?1, ?2, ?3)",
params![txid, mined_height, mined_height],
)
.unwrap();
conn.last_insert_rowid()
}
fn insert_orchard_note(
conn: &Connection,
account_ref: i64,
orchard_fvk: &orchard::keys::FullViewingKey,
note_tag: u8,
mined_height: u32,
value_zatoshi: u64,
commitment_tree_position: u64,
) {
let transaction_id = insert_transaction(conn, note_tag, mined_height);
let note = test_orchard_note(orchard_fvk, note_tag, value_zatoshi);
let nullifier = note.nullifier(orchard_fvk);
conn.execute(
"INSERT INTO orchard_received_notes (
transaction_id, action_index, account_id, diversifier, value, rho, rseed,
nf, is_change, commitment_tree_position, recipient_key_scope
)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 0, ?9, 0)",
params![
transaction_id,
i64::from(note_tag),
account_ref,
note.recipient().diversifier().as_array(),
value_zatoshi,
note.rho().to_bytes(),
note.rseed().as_bytes(),
nullifier.to_bytes(),
commitment_tree_position,
],
)
.unwrap();
}
fn test_orchard_note(
orchard_fvk: &orchard::keys::FullViewingKey,
note_tag: u8,
value_zatoshi: u64,
) -> orchard::Note {
let recipient = orchard_fvk.address_at(u64::from(note_tag), Scope::External);
let rho = rho_from_nonce(u64::from(note_tag) + 1);
for seed_nonce in 1..10_000 {
let mut seed = [0u8; 32];
seed[..8].copy_from_slice(&(seed_nonce + u64::from(note_tag) * 10_000).to_le_bytes());
if let Some(rseed) = Option::<RandomSeed>::from(RandomSeed::from_bytes(seed, &rho)) {
if let Some(note) = Option::<orchard::Note>::from(orchard::Note::from_parts(
recipient,
NoteValue::from_raw(value_zatoshi),
rho,
rseed,
)) {
return note;
}
}
}
panic!("failed to generate valid Orchard note fixture");
}
fn rho_from_nonce(nonce: u64) -> Rho {
let mut bytes = [0u8; 32];
bytes[..8].copy_from_slice(&nonce.to_le_bytes());
Option::<Rho>::from(Rho::from_bytes(&bytes))
.expect("small integers are valid pallas base field elements")
}
#[test]
fn display_memo_uses_raw_zec_precision() {
assert_eq!(
display_memo("Poll", 123_456_789),
"I am authorizing this hotkey managed by my wallet to vote on Poll.\nAmount: 1.23456789 ZEC."
);
}
#[test]
fn display_memo_truncates_long_messages() {
let memo = display_memo(&"A".repeat(600), crate::governance::BALLOT_DIVISOR);
let expected_suffix = format!(
".\nAmount: {}.{:08} ZEC.",
crate::governance::BALLOT_DIVISOR / 100_000_000,
crate::governance::BALLOT_DIVISOR % 100_000_000
);
assert_eq!(memo.len(), 512);
assert!(memo.starts_with("I am authorizing this hotkey"));
assert!(memo.ends_with(&expected_suffix));
}
#[test]
fn display_memo_truncation_preserves_utf8_boundaries() {
let memo = display_memo(&"é".repeat(300), crate::governance::BALLOT_DIVISOR);
let expected_suffix = format!(
".\nAmount: {}.{:08} ZEC.",
crate::governance::BALLOT_DIVISOR / 100_000_000,
crate::governance::BALLOT_DIVISOR % 100_000_000
);
assert!(memo.len() <= 512);
assert!(memo.ends_with(&expected_suffix));
assert!(memo.is_char_boundary(memo.len()));
}
#[test]
fn display_memo_keeps_amount_line_for_varied_amount_magnitudes() {
let amount_cases = [
0_u64,
1_u64,
99_999_999_u64,
100_000_000_u64,
1_234_567_890_123_456_u64,
u64::MAX,
];
for amount in amount_cases {
let memo = display_memo("Poll", amount);
let expected_amount_line =
format!("Amount: {}.{:08} ZEC.", amount / 100_000_000, amount % 100_000_000);
assert!(memo.contains("\nAmount: "));
assert!(
memo.ends_with(&expected_amount_line),
"memo should preserve amount line for amount={amount}, memo={memo}"
);
assert!(memo.len() <= 512);
}
}
#[test]
fn display_memo_allows_full_ascii_title_when_exactly_at_budget() {
const DISPLAY_MEMO_MAX_BYTES: usize = 512;
const DISPLAY_MEMO_PREFIX: &str =
"I am authorizing this hotkey managed by my wallet to vote on ";
const DISPLAY_MEMO_ROUND_SUFFIX: &str = ".\nAmount:";
let amount = 9_999_999_999_999_999_u64;
let amount_suffix = format!(" {}.{:08} ZEC.", amount / 100_000_000, amount % 100_000_000);
let title_budget = DISPLAY_MEMO_MAX_BYTES
- DISPLAY_MEMO_PREFIX.len()
- DISPLAY_MEMO_ROUND_SUFFIX.len()
- amount_suffix.len();
let title = "A".repeat(title_budget);
let memo = display_memo(&title, amount);
assert_eq!(memo.len(), 512);
assert!(memo.contains(&format!("vote on {}", title)));
assert!(memo.ends_with(&format!("Amount:{amount_suffix}")));
}
#[test]
fn display_memo_truncates_over_budget_ascii_title_only() {
const DISPLAY_MEMO_MAX_BYTES: usize = 512;
const DISPLAY_MEMO_PREFIX: &str =
"I am authorizing this hotkey managed by my wallet to vote on ";
const DISPLAY_MEMO_ROUND_SUFFIX: &str = ".\nAmount:";
let amount = 9_999_999_999_999_999_u64;
let amount_suffix = format!(" {}.{:08} ZEC.", amount / 100_000_000, amount % 100_000_000);
let title_budget = DISPLAY_MEMO_MAX_BYTES
- DISPLAY_MEMO_PREFIX.len()
- DISPLAY_MEMO_ROUND_SUFFIX.len()
- amount_suffix.len();
let title = "B".repeat(title_budget + 20);
let memo = display_memo(&title, amount);
assert_eq!(memo.len(), 512);
assert!(memo.contains(&format!("vote on {}", "B".repeat(title_budget))));
assert!(memo.ends_with(&format!("Amount:{amount_suffix}")));
}
#[test]
fn display_memo_truncates_unicode_title_without_breaking_utf8() {
let title = "🙂".repeat(300);
let amount = 42_u64;
let memo = display_memo(&title, amount);
assert!(memo.len() <= 512);
assert!(memo.is_char_boundary(memo.len()));
assert!(memo.contains("\nAmount: "));
assert!(memo.ends_with("Amount: 0.00000042 ZEC."));
}
#[test]
fn delegation_keys_validate_hotkey_address_length() {
let err = DelegationKeys::with_hotkey_bytes(
vec![8; 96],
&[7; 42],
[9; 32],
0,
0,
Network::Testnet,
"Demo Round".to_string(),
)
.unwrap_err()
.to_string();
assert!(err.contains("hotkey_raw_address must be 43 bytes"));
}
#[test]
fn lightwalletd_branch_id_provider_resolved_returns_branch_id() {
let provider = LightwalletdBranchIdProvider::resolved(0x4DEC_4DF0);
assert_eq!(provider.consensus_branch_id().unwrap(), 0x4DEC_4DF0);
}
#[test]
fn external_signature_signer_validates_signature_shapes() {
assert!(matches!(
DelegationSigner::signature_from_bytes(&[1; 64], &[2; 32]).unwrap(),
DelegationSigner::Signature { .. }
));
let sig_err = match DelegationSigner::signature_from_bytes(&[1; 63], &[2; 32]) {
Ok(_) => panic!("short signature should be rejected"),
Err(err) => err.to_string(),
};
let sighash_err = match DelegationSigner::signature_from_bytes(&[1; 64], &[2; 31]) {
Ok(_) => panic!("short sighash should be rejected"),
Err(err) => err.to_string(),
};
assert!(sig_err.contains("signature must be 64 bytes"));
assert!(sighash_err.contains("sighash must be 32 bytes"));
}
#[test]
fn load_account_keys_rejects_malformed_uuid() {
let db = WalletDb::from_connection(
rusqlite::Connection::open_in_memory().unwrap(),
zcash_protocol::consensus::Network::TestNetwork,
zcash_client_sqlite::util::SystemClock,
rand::rngs::OsRng,
);
let err = load_account_keys(&db, "not-a-uuid")
.unwrap_err()
.to_string();
assert!(err.contains("invalid account UUID"));
}
#[test]
fn gather_delegation_wallet_inputs_rejects_malformed_uuid() {
let db = WalletDb::from_connection(
rusqlite::Connection::open_in_memory().unwrap(),
ZcashNetwork::TestNetwork,
SystemClock,
rand::rngs::OsRng,
);
let hotkey = test_voting_hotkey();
let err = gather_delegation_wallet_inputs(GatherDelegationWalletParams {
wallet_db: &db,
account_uuid: "not-a-uuid",
voting_hotkey: &hotkey,
snapshot_height: 12,
scanned_height: 12,
anchor_tree_state_bytes: vec![0xAA, 0xBB],
resolved_round_name: "Demo Round".to_string(),
})
.unwrap_err()
.to_string();
assert!(err.contains("invalid account UUID"));
}
#[test]
fn gather_delegation_wallet_inputs_rejects_unsynced_wallet() {
let db = WalletDb::from_connection(
rusqlite::Connection::open_in_memory().unwrap(),
ZcashNetwork::TestNetwork,
SystemClock,
rand::rngs::OsRng,
);
let hotkey = test_voting_hotkey();
let err = gather_delegation_wallet_inputs(GatherDelegationWalletParams {
wallet_db: &db,
account_uuid: "550e8400-e29b-41d4-a716-446655440000",
voting_hotkey: &hotkey,
snapshot_height: 12,
scanned_height: 11,
anchor_tree_state_bytes: vec![0xAA, 0xBB],
resolved_round_name: "Demo Round".to_string(),
})
.unwrap_err()
.to_string();
assert!(err.contains("wallet is not synced to voting snapshot height 12"));
}
#[test]
fn gather_delegation_wallet_inputs_selects_sorted_notes_and_keys() {
let mut conn = Connection::open_in_memory().unwrap();
let (account_uuid, orchard_fvk) = setup_test_account(&mut conn);
let account_ref = account_internal_id(&conn, &account_uuid);
let divisor = crate::governance::BALLOT_DIVISOR;
insert_orchard_note(&conn, account_ref, &orchard_fvk, 1, 10, divisor, 7);
insert_orchard_note(&conn, account_ref, &orchard_fvk, 2, 10, divisor * 2, 3);
insert_orchard_note(&conn, account_ref, &orchard_fvk, 3, 16, divisor * 3, 11);
let db = WalletDb::from_connection(
&conn,
ZcashNetwork::TestNetwork,
SystemClock,
rand::rngs::OsRng,
);
let hotkey = test_voting_hotkey();
let inputs = gather_delegation_wallet_inputs(GatherDelegationWalletParams {
wallet_db: &db,
account_uuid: &account_uuid.expose_uuid().to_string(),
voting_hotkey: &hotkey,
snapshot_height: 12,
scanned_height: 12,
anchor_tree_state_bytes: vec![0xAA, 0xBB],
resolved_round_name: "Demo Round".to_string(),
})
.unwrap();
assert_eq!(inputs.anchor_tree_state_bytes, vec![0xAA, 0xBB]);
assert_eq!(inputs.round_note_infos.len(), 2);
assert_eq!(inputs.round_note_infos[0].position, 3);
assert_eq!(inputs.round_note_infos[1].position, 7);
assert_eq!(
&inputs.delegation_keys.hotkey_raw_address,
hotkey.raw_orchard_address()
);
assert_eq!(inputs.delegation_keys.address_index, hotkey.address_index());
assert_eq!(
inputs.delegation_keys.coin_type,
ZcashNetwork::TestNetwork.network_type().coin_type()
);
assert_eq!(inputs.delegation_keys.round_name, "Demo Round");
}
#[test]
fn gather_delegation_wallet_inputs_rejects_empty_snapshot() {
let mut conn = Connection::open_in_memory().unwrap();
let (account_uuid, _) = setup_test_account(&mut conn);
let db = WalletDb::from_connection(
&conn,
ZcashNetwork::TestNetwork,
SystemClock,
rand::rngs::OsRng,
);
let hotkey = test_voting_hotkey();
let err = gather_delegation_wallet_inputs(GatherDelegationWalletParams {
wallet_db: &db,
account_uuid: &account_uuid.expose_uuid().to_string(),
voting_hotkey: &hotkey,
snapshot_height: 12,
scanned_height: 12,
anchor_tree_state_bytes: vec![0xAA, 0xBB],
resolved_round_name: "Demo Round".to_string(),
})
.unwrap_err()
.to_string();
assert!(err.contains("no spendable voting notes"));
}
#[test]
fn gather_delegation_wallet_inputs_rejects_network_mismatch() {
let mut conn = Connection::open_in_memory().unwrap();
let (account_uuid, orchard_fvk) = setup_test_account(&mut conn);
let account_ref = account_internal_id(&conn, &account_uuid);
insert_orchard_note(
&conn,
account_ref,
&orchard_fvk,
1,
10,
crate::governance::BALLOT_DIVISOR,
7,
);
let db = WalletDb::from_connection(
&conn,
ZcashNetwork::TestNetwork,
SystemClock,
rand::rngs::OsRng,
);
let hotkey = VotingHotkey::from_stored_secret(&[0x77; 64], Network::Mainnet).unwrap();
let err = gather_delegation_wallet_inputs(GatherDelegationWalletParams {
wallet_db: &db,
account_uuid: &account_uuid.expose_uuid().to_string(),
voting_hotkey: &hotkey,
snapshot_height: 12,
scanned_height: 12,
anchor_tree_state_bytes: vec![0xAA, 0xBB],
resolved_round_name: "Demo Round".to_string(),
})
.unwrap_err()
.to_string();
assert!(err.contains("voting hotkey network does not match wallet DB network"));
}
#[test]
fn redact_delegation_pczt_for_signer_rejects_invalid_pczt_bytes() {
let err = redact_delegation_pczt_for_signer(&[0xFF, 0x00])
.unwrap_err()
.to_string();
assert!(err.contains("parse PCZT failed"));
}
}