From 0f9478314c44632da2193aeb0f3ac4fc988e36b8 Mon Sep 17 00:00:00 2001 From: roman Date: Thu, 28 May 2026 12:14:19 -0300 Subject: [PATCH] Add stable delegation API surface --- zcash_voting/Cargo.toml | 9 +- zcash_voting/README.md | 54 ++++- zcash_voting/src/delegate.rs | 249 ++++++++++++++++++++ zcash_voting/src/error.rs | 7 + zcash_voting/src/http_transport.rs | 26 +-- zcash_voting/src/lib.rs | 44 +++- zcash_voting/src/phases.rs | 266 +++++++++++++++++++++ zcash_voting/src/pir.rs | 29 +++ zcash_voting/src/precompute.rs | 117 ++++++++++ zcash_voting/src/prelude.rs | 26 +++ zcash_voting/src/round/mod.rs | 310 +++++++++++++++++++++++++ zcash_voting/src/storage/operations.rs | 29 +-- zcash_voting/src/storage/queries.rs | 2 +- zcash_voting/src/transport.rs | 9 + zcash_voting/src/types.rs | 40 ++++ zcash_voting/src/zkp1.rs | 14 +- 16 files changed, 1179 insertions(+), 52 deletions(-) create mode 100644 zcash_voting/src/delegate.rs create mode 100644 zcash_voting/src/error.rs create mode 100644 zcash_voting/src/phases.rs create mode 100644 zcash_voting/src/pir.rs create mode 100644 zcash_voting/src/precompute.rs create mode 100644 zcash_voting/src/prelude.rs create mode 100644 zcash_voting/src/round/mod.rs create mode 100644 zcash_voting/src/transport.rs diff --git a/zcash_voting/Cargo.toml b/zcash_voting/Cargo.toml index bae46482..b247a2b9 100644 --- a/zcash_voting/Cargo.toml +++ b/zcash_voting/Cargo.toml @@ -11,7 +11,7 @@ readme = "README.md" default = [] # PIR client surface for fetching IMT non-membership proofs over direct # Hyper/Rustls HTTP. -client-pir = [ +pir = [ "dep:imt-tree", "dep:pir-client", "dep:pir-types", @@ -25,7 +25,7 @@ client-pir = [ "dep:hyper-util", ] # Vote commitment tree HTTP sync surface for VAN witness generation. -client-tree-sync = [ +tree-sync = [ "dep:imt-tree", "dep:vote-commitment-tree", "dep:vote-commitment-tree-client", @@ -37,8 +37,11 @@ client-tree-sync = [ "dep:hyper-util", "dep:tokio", ] +# Legacy feature names kept as aliases during the wallet migration window. +client-pir = ["pir"] +client-tree-sync = ["tree-sync"] # Backwards-compatible aggregate for consumers that already enable `client`. -client = ["client-pir", "client-tree-sync"] +client = ["pir", "tree-sync"] # Downstream test-only helpers for setting up persisted voting state without # depending on SQLite schema details. test-fixtures = [] diff --git a/zcash_voting/README.md b/zcash_voting/README.md index 070f2b5e..dcc3cd28 100644 --- a/zcash_voting/README.md +++ b/zcash_voting/README.md @@ -4,23 +4,43 @@ Client-side library for integrating [Zcash shielded voting](https://github.com/v ## Usage -Wallets typically consume this through a language bridge: - -- **Rust wallets**: add `zcash_voting = "0.9"` to `Cargo.toml`. -- **Mobile wallets**: expose the needed Rust APIs through the wallet SDK's FFI - layer and keep platform-specific work, such as CSPRNG byte generation and - HTTP submission, at the SDK boundary. - -See the [wallet integration guide](https://github.com/valargroup/vote-sdk/blob/main/docs/wallet-integration.md) for the full flow. +Wallets should import `zcash_voting::prelude::*` and follow the stable setup → +precompute → delegate lifecycle: + +1. Open a `VotingDb`, set the wallet id, and call `create_round`. +2. Convert eligible Orchard notes into `NoteInfo` with + `NoteInfo::from_orchard_note`, then call `ensure_bundles`. +3. Build the governance PCZT with `setup_delegation`. +4. Precompute delegation inputs with `note_witnesses` and, with the `pir` + feature, `delegation_pir`. +5. Prove with `delegate::prove`, assemble submission fields with + `delegation_submission`, and record chain recovery data with + `record_submission` and `record_van_position`. ## Crate layout | Crate | Purpose | |---|---| -| **`zcash_voting`** (this crate) | Top-level API: proof generation, hotkey derivation, share construction, PCZT assembly, round-state storage. | +| **`zcash_voting`** (this crate) | Stable wallet API: round setup, note bundles, delegation precompute/proving, hotkey derivation, and round-state storage. | | [`vote-commitment-tree`](../vote-commitment-tree) | Append-only Poseidon Merkle tree for VANs and vote commitments. | | [`vote-commitment-tree-client`](../vote-commitment-tree-client) | HTTP client + CLI for syncing the vote commitment tree from a running chain node. | +## Public modules + +| Module | Purpose | +|---|---| +| `prelude` | Recommended imports for wallet SDKs. | +| `round` | `VotingDb`, `RoundParams`, `RoundInfo`, and idempotent `ensure_bundles`. | +| `precompute` | Orchard note witness generation and PIR precompute wrappers. | +| `delegate` | PCZT setup, proof generation, submission assembly, and chain recovery writes. | +| `phases` | Per-bundle `DelegationPhase` derived from persisted artifacts. | +| `pir` | PIR endpoint selection helpers and client re-exports. | +| `hotkey` | Primitive hotkey derivation from caller-supplied seed bytes. | +| `governance` | Low-level governance derivations and `BALLOT_DIVISOR`. | + +Lower-level modules from previous releases remain available during the 0.11 +migration window, but new wallet code should prefer the lifecycle modules above. + ## Shared wallet policy helpers The `share_policy` module contains pure helpers for wallet-side voting behavior @@ -41,12 +61,26 @@ crate own the sampling and ordering policy. - **`orchard 0.13.1`** from crates.io, with the `unstable-voting-circuits` feature enabled for the governance proof paths. -- **`voting-circuits 0.5.0`** for the delegation and vote proof circuits. +- **`voting-circuits 0.6.0`** for the delegation and vote proof circuits. - **`vote-commitment-tree 0.3`** and **`vote-commitment-tree-client 0.5`** for vote commitment tree state and optional HTTP sync. - **`pczt`, `zcash_keys`, `zcash_primitives`, and `zcash_protocol`** from the published upstream Zcash crate line used by this release. +## Migrating from 0.10 + +- Enable `pir` and `tree-sync` instead of `client-pir` and + `client-tree-sync`. The old feature names remain aliases for existing + consumers during migration. +- Prefer `VotingDb::create_round`, `VotingDb::ensure_bundles`, and + `VotingDb::delegation_phases` over direct `storage::queries` calls. +- Use `precompute::note_witnesses` instead of hand-validating cached + `TreeState` bytes and manually constructing `WitnessData`. +- Use `delegate::submission` with `DelegationSigner::Seed` or + `DelegationSigner::Keystone` instead of separate submission methods. +- Treat contextual hotkey mixing as wallet policy. The library intentionally + keeps `generate_hotkey(seed)` primitive. + ## License Dual-licensed under MIT or Apache-2.0. See [LICENSE-MIT](../LICENSE-MIT) and [LICENSE-APACHE](../LICENSE-APACHE). diff --git a/zcash_voting/src/delegate.rs b/zcash_voting/src/delegate.rs new file mode 100644 index 00000000..4193ef78 --- /dev/null +++ b/zcash_voting/src/delegate.rs @@ -0,0 +1,249 @@ +//! Delegation lifecycle API. +//! +//! The functions in this module are the stable wallet-facing delegation flow: +//! build a governance PCZT, precompute proof inputs, prove delegation, assemble +//! chain submission data, and record chain recovery data. + +pub use crate::phases::DelegationPhase; + +use crate::{ + round::VotingDb, + types::{Network, NoteInfo, ProgressReporter, VotingError}, +}; + +/// Wallet-derived keys and chain parameters needed to build a delegation PCZT. +#[derive(Clone, Debug)] +pub struct DelegationKeys { + pub fvk_bytes: Vec, + pub hotkey_raw_address: [u8; 43], + pub seed_fingerprint: [u8; 32], + pub account_index: u32, + pub address_index: u32, + pub consensus_branch_id: u32, + pub coin_type: u32, + pub round_name: String, +} + +/// PCZT setup output that callers hand to a signer or QR encoder. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DelegationSetup { + pub pczt_bytes: Vec, + pub pczt_sighash: [u8; 32], + pub rk: [u8; 32], + pub action_index: usize, + pub action_bytes: Vec, +} + +/// Generated delegation proof and public submission fields for one bundle. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DelegationProof { + pub bytes: Vec, + pub rk: [u8; 32], + pub nf_signed: [u8; 32], + pub cmx_new: [u8; 32], + pub van_comm: [u8; 32], + pub gov_nullifiers: [[u8; 32]; 5], +} + +/// Signature source used when assembling a delegation transaction payload. +pub enum DelegationSigner<'a> { + Seed { + seed: &'a [u8], + network: Network, + account_index: u32, + }, + Keystone { + sig: [u8; 64], + sighash: [u8; 32], + }, +} + +/// Chain-ready delegation transaction fields for one bundle. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DelegationSubmission { + pub proof: Vec, + pub rk: [u8; 32], + pub nf_signed: [u8; 32], + pub cmx_new: [u8; 32], + pub gov_comm: [u8; 32], + pub gov_nullifiers: [[u8; 32]; 5], + pub alpha: [u8; 32], + pub vote_round_id: String, + pub spend_auth_sig: [u8; 64], + pub sighash: [u8; 32], +} + +/// Builds and persists a governance PCZT for one bundle. +/// +/// The bundle must already exist via [`VotingDb::ensure_bundles`]. The returned +/// sighash is the exact message that Keystone or the seed signer must sign. +pub fn setup( + db: &VotingDb, + round_id: &str, + bundle_index: u32, + notes: &[NoteInfo], + keys: &DelegationKeys, +) -> Result { + let pczt = db.build_governance_pczt( + round_id, + bundle_index, + notes, + &keys.fvk_bytes, + &keys.hotkey_raw_address, + keys.consensus_branch_id, + keys.coin_type, + &keys.seed_fingerprint, + keys.account_index, + &keys.round_name, + keys.address_index, + )?; + + Ok(DelegationSetup { + pczt_bytes: pczt.pczt_bytes, + pczt_sighash: array32("pczt_sighash", pczt.pczt_sighash)?, + rk: array32("rk", pczt.rk)?, + action_index: pczt.action_index, + action_bytes: pczt.action_bytes, + }) +} + +/// Generates and persists the delegation proof for one bundle. +/// +/// Witnesses and PIR proof precompute data must already be present. The proof +/// result is checked against PCZT-derived public fields before persistence. +#[cfg(feature = "pir")] +pub fn prove( + db: &VotingDb, + round_id: &str, + bundle_index: u32, + notes: &[NoteInfo], + hotkey_raw_address: &[u8; 43], + pir_client: &pir_client::PirClientBlocking, + network: Network, + progress: &dyn ProgressReporter, +) -> Result { + let proof = db.build_and_prove_delegation( + round_id, + bundle_index, + notes, + hotkey_raw_address, + pir_client, + network.id(), + progress, + )?; + + 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: array32x5("gov_nullifiers", proof.gov_nullifiers)?, + }) +} + +/// Assembles chain-ready delegation submission fields for one bundle. +/// +/// Seed signers derive the spend authorization key internally. Keystone signers +/// must provide the signature over the stored PCZT sighash. +pub fn submission( + db: &VotingDb, + round_id: &str, + bundle_index: u32, + signer: DelegationSigner<'_>, +) -> Result { + let data = match signer { + DelegationSigner::Seed { + seed, + network, + account_index, + } => { + db.get_delegation_submission(round_id, bundle_index, seed, network.id(), account_index) + } + DelegationSigner::Keystone { sig, sighash } => { + db.get_delegation_submission_with_keystone_sig(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: array32x5("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)?, + }) +} + +/// Records the submitted delegation transaction hash for recovery. +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) +} + +/// Records the confirmed VAN leaf position for a delegated bundle. +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) +} + +/// Extracts the ZIP-244 shielded sighash from a serialized voting PCZT. +pub fn pczt_sighash(pczt_bytes: &[u8]) -> Result<[u8; 32], VotingError> { + crate::action::extract_pczt_sighash(pczt_bytes) +} + +/// Extracts one SpendAuth signature from a Keystone-signed voting PCZT. +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 array32(label: &str, value: Vec) -> Result<[u8; 32], VotingError> { + value + .try_into() + .map_err(|value: Vec| VotingError::Internal { + message: format!("{label} must be 32 bytes, got {}", value.len()), + }) +} + +fn array64(label: &str, value: Vec) -> Result<[u8; 64], VotingError> { + value + .try_into() + .map_err(|value: Vec| VotingError::Internal { + message: format!("{label} must be 64 bytes, got {}", value.len()), + }) +} + +fn array32x5(label: &str, values: Vec>) -> Result<[[u8; 32]; 5], VotingError> { + if values.len() != 5 { + return Err(VotingError::Internal { + message: format!("{label} must contain 5 entries, got {}", values.len()), + }); + } + + let arrays = values + .into_iter() + .enumerate() + .map(|(idx, value)| array32(&format!("{label}[{idx}]"), value)) + .collect::, _>>()?; + + arrays + .try_into() + .map_err(|arrays: Vec<[u8; 32]>| VotingError::Internal { + message: format!("{label} must contain 5 entries, got {}", arrays.len()), + }) +} diff --git a/zcash_voting/src/error.rs b/zcash_voting/src/error.rs new file mode 100644 index 00000000..1f7e22c9 --- /dev/null +++ b/zcash_voting/src/error.rs @@ -0,0 +1,7 @@ +//! Public error surface for wallet integrations. +//! +//! The voting API uses one error type across setup, precompute, delegation, and +//! persistence operations. Re-exporting it from this module gives downstream +//! SDKs a stable import path even as internal modules move. + +pub use crate::types::VotingError; diff --git a/zcash_voting/src/http_transport.rs b/zcash_voting/src/http_transport.rs index f03b95fc..625453da 100644 --- a/zcash_voting/src/http_transport.rs +++ b/zcash_voting/src/http_transport.rs @@ -1,4 +1,4 @@ -#[cfg(feature = "client-tree-sync")] +#[cfg(any(feature = "tree-sync", feature = "client-tree-sync"))] use std::future::Future; use anyhow::{Context, Result}; @@ -16,7 +16,7 @@ type HyperClient = Client, RequestBody>; struct HyperResponse { status: u16, - #[cfg(feature = "client-pir")] + #[cfg(any(feature = "pir", feature = "client-pir"))] headers: Vec<(String, String)>, body: Vec, } @@ -28,13 +28,13 @@ struct HyperResponse { /// direct cleartext/HTTPS traffic without providing their own transport. pub struct HyperTransport { client: HyperClient, - #[cfg(feature = "client-tree-sync")] + #[cfg(any(feature = "tree-sync", feature = "client-tree-sync"))] runtime: BlockingRuntime, } impl HyperTransport { pub fn new() -> Self { - #[cfg(feature = "client-tree-sync")] + #[cfg(any(feature = "tree-sync", feature = "client-tree-sync"))] let runtime = BlockingRuntime::new(); let mut connector = HttpConnector::new(); connector.enforce_http(false); @@ -48,7 +48,7 @@ impl HyperTransport { Self { client, - #[cfg(feature = "client-tree-sync")] + #[cfg(any(feature = "tree-sync", feature = "client-tree-sync"))] runtime, } } @@ -65,7 +65,7 @@ impl HyperTransport { .await .context("send HTTP request")?; let status = response.status().as_u16(); - #[cfg(feature = "client-pir")] + #[cfg(any(feature = "pir", feature = "client-pir"))] let headers = response .headers() .iter() @@ -86,7 +86,7 @@ impl HyperTransport { Ok(HyperResponse { status, - #[cfg(feature = "client-pir")] + #[cfg(any(feature = "pir", feature = "client-pir"))] headers, body, }) @@ -99,12 +99,12 @@ impl Default for HyperTransport { } } -#[cfg(feature = "client-tree-sync")] +#[cfg(any(feature = "tree-sync", feature = "client-tree-sync"))] struct BlockingRuntime { inner: Option, } -#[cfg(feature = "client-tree-sync")] +#[cfg(any(feature = "tree-sync", feature = "client-tree-sync"))] impl BlockingRuntime { fn new() -> Self { Self { @@ -125,7 +125,7 @@ impl BlockingRuntime { } } -#[cfg(feature = "client-tree-sync")] +#[cfg(any(feature = "tree-sync", feature = "client-tree-sync"))] impl Drop for BlockingRuntime { fn drop(&mut self) { if let Some(runtime) = self.inner.take() { @@ -134,7 +134,7 @@ impl Drop for BlockingRuntime { } } -#[cfg(feature = "client-pir")] +#[cfg(any(feature = "pir", feature = "client-pir"))] impl pir_client::Transport for HyperTransport { fn get<'a>(&'a self, url: &'a str) -> pir_client::TransportFuture<'a> { Box::pin(async move { @@ -161,7 +161,7 @@ impl pir_client::Transport for HyperTransport { } } -#[cfg(feature = "client-tree-sync")] +#[cfg(any(feature = "tree-sync", feature = "client-tree-sync"))] impl vote_commitment_tree_client::transport::Transport for HyperTransport { fn get( &self, @@ -184,7 +184,7 @@ impl vote_commitment_tree_client::transport::Transport for HyperTransport { } } -#[cfg(all(test, feature = "client-tree-sync"))] +#[cfg(all(test, any(feature = "tree-sync", feature = "client-tree-sync")))] mod tests { use super::BlockingRuntime; diff --git a/zcash_voting/src/lib.rs b/zcash_voting/src/lib.rs index 3637f6e6..e3dddad5 100644 --- a/zcash_voting/src/lib.rs +++ b/zcash_voting/src/lib.rs @@ -1,16 +1,38 @@ +//! Client-side APIs for Zcash shielded voting. +//! +//! Wallet SDKs should import [`prelude`] and follow the lifecycle: +//! create a round, bind eligible notes into bundles, precompute witness/PIR +//! data, build a delegation PCZT, prove delegation, and finally record chain +//! submission data. Lower-level modules remain available for the current +//! 0.10-to-0.11 migration window, but new integrations should use `round`, +//! `precompute`, and `delegate`. + pub mod action; pub mod decompose; +pub mod delegate; pub mod elgamal; +pub mod error; pub mod governance; pub mod hotkey; -#[cfg(any(feature = "client-pir", feature = "client-tree-sync"))] +#[cfg(any( + feature = "pir", + feature = "tree-sync", + feature = "client-pir", + feature = "client-tree-sync" +))] mod http_transport; pub mod note_bundling; +pub mod phases; +pub mod pir; pub mod pir_snapshot; +pub mod precompute; +pub mod prelude; +pub mod round; pub mod share_policy; pub mod share_tracking; pub mod storage; -#[cfg(feature = "client-tree-sync")] +pub mod transport; +#[cfg(any(feature = "tree-sync", feature = "client-tree-sync"))] pub mod tree_sync; pub mod types; pub mod vote_commitment; @@ -18,15 +40,27 @@ pub mod witness; pub mod zkp1; pub mod zkp2; -#[cfg(any(feature = "client-pir", feature = "client-tree-sync"))] +#[cfg(any( + feature = "pir", + feature = "tree-sync", + feature = "client-pir", + feature = "client-tree-sync" +))] pub use http_transport::HyperTransport; -#[cfg(feature = "client-pir")] +#[cfg(any(feature = "pir", feature = "client-pir"))] pub use pir_client::{ ImtProofData, PirClient, PirClientBlocking, Transport, TransportFuture, TransportResponse, }; pub use governance::BALLOT_DIVISOR; -pub use types::*; +pub use types::{ + validate_round_params, BundleSetupResult, CastVoteSignature, DelegationAction, + DelegationPirPrecomputeResult, DelegationProofResult, DelegationSubmissionData, EncryptedShare, + GovernancePczt, Network, NoopProgressReporter, NoteInfo, PreparedDelegationPirResult, + ProgressReporter, ProofProgressReporter, ShareDelegationRecord, SharePayload, + VoteCommitmentBundle, VotingError, VotingHotkey, VotingRoundParams, WireEncryptedShare, + WitnessData, +}; /// Warm process-lifetime proving-key caches used by on-device voting proofs. /// diff --git a/zcash_voting/src/phases.rs b/zcash_voting/src/phases.rs new file mode 100644 index 00000000..19021e44 --- /dev/null +++ b/zcash_voting/src/phases.rs @@ -0,0 +1,266 @@ +//! Canonical per-artifact lifecycle phases. +//! +//! A voting round can contain multiple bundles. Each bundle may progress at a +//! different pace, so the stable API reports delegation status per bundle +//! instead of maintaining one lossy round-level phase. + +use rusqlite::{named_params, OptionalExtension}; + +use crate::{storage::VotingDb, types::VotingError}; + +/// Delegation lifecycle for one bundle. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum DelegationPhase { + /// The bundle row exists and is bound to its note identities. + Prepared, + /// The governance PCZT and signing fields have been persisted. + PcztBuilt, + /// The ZKP #1 delegation proof has been generated and persisted. + Proved, + /// A delegation transaction hash has been recorded. + Submitted, + /// The vote authority note leaf position has been recovered from chain. + Confirmed, +} + +impl DelegationPhase { + /// Returns the stable string used by FFI layers and UI state machines. + pub fn as_str(self) -> &'static str { + match self { + Self::Prepared => "prepared", + Self::PcztBuilt => "pczt_built", + Self::Proved => "proved", + Self::Submitted => "submitted", + Self::Confirmed => "confirmed", + } + } +} + +impl VotingDb { + /// Loads the canonical delegation phase for one bundle. + /// + /// Returns [`VotingError::InvalidInput`] when the bundle row does not exist + /// for the current wallet. + pub fn delegation_phase( + &self, + round_id: &str, + bundle_index: u32, + ) -> Result { + let conn = self.conn(); + let wallet_id = self.wallet_id(); + let phase = conn + .query_row( + "SELECT b.pczt_sighash IS NOT NULL OR b.rk IS NOT NULL, + EXISTS( + SELECT 1 FROM proofs p + WHERE p.round_id = b.round_id + AND p.wallet_id = b.wallet_id + AND p.bundle_index = b.bundle_index + AND p.success = 1 + ), + b.delegation_tx_hash IS NOT NULL, + b.van_leaf_position IS NOT NULL + FROM bundles b + WHERE b.round_id = :round_id + AND b.wallet_id = :wallet_id + AND b.bundle_index = :bundle_index", + named_params! { + ":round_id": round_id, + ":wallet_id": wallet_id, + ":bundle_index": bundle_index as i64, + }, + |row| { + Ok(phase_from_columns( + row.get::<_, i64>(0)? != 0, + row.get::<_, i64>(1)? != 0, + row.get::<_, i64>(2)? != 0, + row.get::<_, i64>(3)? != 0, + )) + }, + ) + .optional() + .map_err(|e| VotingError::Internal { + message: format!("failed to load delegation phase: {e}"), + })?; + + phase.ok_or_else(|| VotingError::InvalidInput { + message: format!("bundle not found for round {round_id} index {bundle_index}"), + }) + } + + /// Lists canonical delegation phases for all bundles in one round. + /// + /// Results are sorted by `bundle_index` and scoped to the current wallet id. + pub fn delegation_phases( + &self, + round_id: &str, + ) -> Result, VotingError> { + let conn = self.conn(); + let wallet_id = self.wallet_id(); + let mut stmt = conn + .prepare( + "SELECT b.bundle_index, + b.pczt_sighash IS NOT NULL OR b.rk IS NOT NULL, + EXISTS( + SELECT 1 FROM proofs p + WHERE p.round_id = b.round_id + AND p.wallet_id = b.wallet_id + AND p.bundle_index = b.bundle_index + AND p.success = 1 + ), + b.delegation_tx_hash IS NOT NULL, + b.van_leaf_position IS NOT NULL + FROM bundles b + WHERE b.round_id = :round_id + AND b.wallet_id = :wallet_id + ORDER BY b.bundle_index", + ) + .map_err(|e| VotingError::Internal { + message: format!("failed to prepare delegation phases query: {e}"), + })?; + + let rows = stmt + .query_map( + named_params! { ":round_id": round_id, ":wallet_id": wallet_id }, + |row| { + Ok(( + row.get::<_, i64>(0)? as u32, + phase_from_columns( + row.get::<_, i64>(1)? != 0, + row.get::<_, i64>(2)? != 0, + row.get::<_, i64>(3)? != 0, + row.get::<_, i64>(4)? != 0, + ), + )) + }, + ) + .map_err(|e| VotingError::Internal { + message: format!("failed to query delegation phases: {e}"), + })? + .collect::, _>>() + .map_err(|e| VotingError::Internal { + message: format!("failed to read delegation phase row: {e}"), + })?; + + Ok(rows) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{round::RoundParams, storage::VotingDb, types::NoteInfo}; + + const ROUND_ID: &str = "0101010101010101010101010101010101010101010101010101010101010101"; + const WALLET_ID: &str = "wallet"; + + fn db_with_bundle() -> VotingDb { + let db = VotingDb::open_in_memory().unwrap(); + db.set_wallet_id(WALLET_ID); + db.create_round(&round_params()).unwrap(); + db.ensure_bundles(ROUND_ID, &[note(0)]).unwrap(); + db + } + + fn round_params() -> RoundParams { + RoundParams { + vote_round_id: ROUND_ID.to_string(), + snapshot_height: 1000, + ea_pk: vec![0xEA; 32], + nc_root: vec![0xAA; 32], + nullifier_imt_root: vec![0xBB; 32], + } + } + + fn note(position: u64) -> NoteInfo { + NoteInfo { + commitment: vec![0x01; 32], + nullifier: vec![0x02; 32], + value: crate::governance::BALLOT_DIVISOR, + position, + diversifier: vec![0x03; 11], + rho: vec![0x04; 32], + rseed: vec![0x05; 32], + scope: 0, + ufvk_str: "uview1test".to_string(), + } + } + + #[test] + fn delegation_phase_advances_from_persisted_artifacts() { + let db = db_with_bundle(); + assert_eq!( + db.delegation_phase(ROUND_ID, 0).unwrap(), + DelegationPhase::Prepared + ); + + db.conn() + .execute( + "UPDATE bundles SET pczt_sighash = X'01', rk = X'02' + WHERE round_id = ?1 AND wallet_id = ?2 AND bundle_index = 0", + rusqlite::params![ROUND_ID, WALLET_ID], + ) + .unwrap(); + assert_eq!( + db.delegation_phase(ROUND_ID, 0).unwrap(), + DelegationPhase::PcztBuilt + ); + + crate::storage::queries::store_proof(&db.conn(), ROUND_ID, WALLET_ID, 0, &[0xAB; 96]) + .unwrap(); + assert_eq!( + db.delegation_phase(ROUND_ID, 0).unwrap(), + DelegationPhase::Proved + ); + + db.store_delegation_tx_hash(ROUND_ID, 0, "tx").unwrap(); + assert_eq!( + db.delegation_phase(ROUND_ID, 0).unwrap(), + DelegationPhase::Submitted + ); + + db.store_van_position(ROUND_ID, 0, 42).unwrap(); + assert_eq!( + db.delegation_phase(ROUND_ID, 0).unwrap(), + DelegationPhase::Confirmed + ); + } + + #[test] + fn delegation_phases_are_sorted_by_bundle_index() { + let db = VotingDb::open_in_memory().unwrap(); + db.set_wallet_id(WALLET_ID); + db.create_round(&round_params()).unwrap(); + db.ensure_bundles( + ROUND_ID, + &[note(0), note(1), note(2), note(3), note(4), note(5)], + ) + .unwrap(); + + let phases = db.delegation_phases(ROUND_ID).unwrap(); + + assert_eq!(phases.len(), 2); + assert_eq!(phases[0], (0, DelegationPhase::Prepared)); + assert_eq!(phases[1], (1, DelegationPhase::Prepared)); + } +} + +fn phase_from_columns( + has_pczt: bool, + has_proof: bool, + has_tx_hash: bool, + has_van_position: bool, +) -> DelegationPhase { + if has_van_position { + DelegationPhase::Confirmed + } else if has_tx_hash { + DelegationPhase::Submitted + } else if has_proof { + DelegationPhase::Proved + } else if has_pczt { + DelegationPhase::PcztBuilt + } else { + DelegationPhase::Prepared + } +} diff --git a/zcash_voting/src/pir.rs b/zcash_voting/src/pir.rs new file mode 100644 index 00000000..8f26ca9b --- /dev/null +++ b/zcash_voting/src/pir.rs @@ -0,0 +1,29 @@ +//! PIR endpoint selection and client re-exports. +//! +//! Wallets use this module to select an exact-height PIR snapshot endpoint +//! before delegation PIR precomputation. + +pub use crate::pir_snapshot::{ + classify_pir_snapshot_height, matching_pir_snapshot_endpoints, select_pir_snapshot_endpoint, + PirSnapshotEndpointDiagnostic, PirSnapshotEndpointStatus, PirSnapshotResolution, +}; + +/// Candidate PIR endpoint URL. +pub type PirEndpoint = String; + +/// Selects an exact-height PIR endpoint from already-probed diagnostics. +/// +/// `match_index` lets callers inject deterministic or random selection without +/// making endpoint probing part of the core API. +pub fn select_pir_endpoint( + diagnostics: &[PirSnapshotEndpointDiagnostic], + snapshot_height: u64, + match_index: u64, +) -> Result { + select_pir_snapshot_endpoint(diagnostics, snapshot_height, match_index) +} + +#[cfg(feature = "pir")] +pub use pir_client::{ + ImtProofData, PirClient, PirClientBlocking, Transport, TransportFuture, TransportResponse, +}; diff --git a/zcash_voting/src/precompute.rs b/zcash_voting/src/precompute.rs new file mode 100644 index 00000000..73000274 --- /dev/null +++ b/zcash_voting/src/precompute.rs @@ -0,0 +1,117 @@ +//! Precomputation APIs for delegation inputs. +//! +//! Precompute operations prepare data that is expensive to derive during proof +//! generation: Orchard note witnesses from the wallet database and PIR-backed +//! non-membership proofs for nullifiers. + +use std::borrow::Borrow; + +use zcash_client_sqlite::WalletDb; + +use crate::{ + round::VotingDb, + types::{Network, NoteInfo, VotingError, WitnessData}, +}; + +/// Result of PIR precomputation for one delegation bundle. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PirPrecomputeReport { + pub cached: u32, + pub fetched: u32, +} + +/// Stores `tree_state_bytes`, generates Orchard witnesses, and caches them. +/// +/// The tree state must be the exact snapshot anchor for the round. The wallet +/// database supplies historical note paths; voting state is persisted in +/// `db`. +pub fn note_witnesses( + db: &VotingDb, + round_id: &str, + bundle_index: u32, + tree_state_bytes: &[u8], + notes: &[NoteInfo], + wallet_db: &WalletDb, +) -> Result, VotingError> +where + C: Borrow, + P: zcash_protocol::consensus::Parameters, +{ + crate::witness::store_tree_state_and_generate_note_witnesses( + db, + round_id, + bundle_index, + tree_state_bytes, + notes, + wallet_db, + ) +} + +/// Loads a round's cached tree state, generates Orchard witnesses, and caches them. +/// +/// This is the FFI-friendly variant for callers that already persisted the +/// round tree state through [`VotingDb`] and should not reach into storage +/// query helpers. +pub fn stored_note_witnesses( + db: &VotingDb, + round_id: &str, + bundle_index: u32, + notes: &[NoteInfo], + wallet_db: &WalletDb, +) -> Result, VotingError> +where + C: Borrow, + P: zcash_protocol::consensus::Parameters, +{ + let tree_state_bytes = { + let conn = db.conn(); + let wallet_id = db.wallet_id(); + crate::storage::queries::load_tree_state(&conn, round_id, &wallet_id)? + }; + note_witnesses( + db, + round_id, + bundle_index, + &tree_state_bytes, + notes, + wallet_db, + ) +} + +/// Verifies an Orchard note witness against its stored root. +/// +/// Returns `Ok(())` when the witness recomputes to the expected root and +/// [`VotingError::InvalidInput`] when the bytes are malformed or mismatched. +pub fn verify_witness(witness: &WitnessData) -> Result<(), VotingError> { + if crate::witness::verify_witness(witness)? { + Ok(()) + } else { + Err(VotingError::InvalidInput { + message: format!( + "witness root mismatch at note position {}", + witness.position + ), + }) + } +} + +/// Fetches and persists PIR-backed IMT non-membership proofs for one bundle. +/// +/// This must run after delegation setup, because padded-note secrets are +/// produced by the PCZT construction step. +#[cfg(feature = "pir")] +pub fn delegation_pir( + db: &VotingDb, + round_id: &str, + bundle_index: u32, + notes: &[NoteInfo], + pir_client: &pir_client::PirClientBlocking, + network: Network, +) -> Result { + let result = + db.precompute_delegation_pir(round_id, bundle_index, notes, pir_client, network.id())?; + Ok(PirPrecomputeReport { + cached: result.cached_count, + fetched: result.fetched_count, + }) +} diff --git a/zcash_voting/src/prelude.rs b/zcash_voting/src/prelude.rs new file mode 100644 index 00000000..7b8e2b55 --- /dev/null +++ b/zcash_voting/src/prelude.rs @@ -0,0 +1,26 @@ +//! Stable imports for wallet SDK integrations. +//! +//! Wallets should prefer this module over importing from internal modules. The +//! prelude intentionally contains the setup, precompute, and delegation types +//! needed by mobile SDK boundaries without exposing proof-circuit internals. + +pub use crate::delegate::{ + pczt_sighash, record_submission, record_van_position, setup as setup_delegation, + spend_auth_signature, submission as delegation_submission, DelegationKeys, DelegationPhase, + DelegationProof, DelegationSetup, DelegationSigner, DelegationSubmission, +}; +pub use crate::error::VotingError; +pub use crate::governance::BALLOT_DIVISOR; +pub use crate::hotkey::generate_hotkey; +pub use crate::pir::{select_pir_endpoint, PirEndpoint}; +pub use crate::precompute::{ + note_witnesses, stored_note_witnesses, verify_witness, PirPrecomputeReport, +}; +pub use crate::round::{note_bundles, BundleLayout, RoundInfo, RoundParams, VotingDb}; +pub use crate::types::{ + Network, NoopProgressReporter, NoteInfo, ProgressReporter, VotingHotkey, WitnessData, +}; +pub use crate::warm_proving_caches; + +#[cfg(feature = "pir")] +pub use crate::precompute::delegation_pir; diff --git a/zcash_voting/src/round/mod.rs b/zcash_voting/src/round/mod.rs new file mode 100644 index 00000000..db17f9e9 --- /dev/null +++ b/zcash_voting/src/round/mod.rs @@ -0,0 +1,310 @@ +//! Round setup and bundle planning API. +//! +//! This module is the stable setup surface for wallet SDKs. It keeps database +//! ownership in [`VotingDb`] while hiding the low-level query helpers that back +//! the SQLite schema. + +use std::path::Path; + +use rusqlite::{named_params, OptionalExtension}; + +use crate::{ + storage::{queries, VotingDb as InnerVotingDb}, + types::{chunk_notes, BundleSetupResult, NoteInfo, VotingError, VotingRoundParams}, +}; + +/// Stable public name for vote-round parameters supplied by the vote chain. +pub type RoundParams = VotingRoundParams; + +/// Public database handle for persisted voting state. +pub type VotingDb = InnerVotingDb; + +/// Query summary for one voting round in the current wallet scope. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RoundInfo { + pub round_id: String, + pub snapshot_height: u64, + pub hotkey_address: Option, + pub eligible_weight: Option, + pub bundle_count: u32, + pub created_at: u64, +} + +/// Result of idempotently planning or validating note bundles for a round. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BundleLayout { + pub bundle_count: u32, + pub eligible_weight: u64, + pub dropped_count: u32, +} + +impl From for BundleLayout { + fn from(result: BundleSetupResult) -> Self { + Self { + bundle_count: result.bundle_count, + eligible_weight: result.eligible_weight_zatoshi, + dropped_count: 0, + } + } +} + +/// Returns the canonical eligible note bundles for a round note set. +/// +/// This is the read-only counterpart to [`VotingDb::ensure_bundles`]. Wallets +/// that need to operate on one bundle after setup can use this instead of +/// depending on the lower-level chunking internals. +pub fn note_bundles(notes: &[NoteInfo]) -> Result>, VotingError> { + crate::types::validate_notes_for_round(notes)?; + Ok(chunk_notes(notes).bundles) +} + +impl VotingDb { + /// Opens or creates a voting database at `path` and runs migrations. + /// + /// Call [`VotingDb::set_wallet_id`] before performing wallet-scoped round + /// operations. Passing `:memory:` is supported through the legacy string + /// API; prefer [`VotingDb::open_in_memory`] for in-memory tests. + pub fn open_path(path: &Path) -> Result { + Self::open(path.to_str().ok_or_else(|| VotingError::InvalidInput { + message: "voting database path is not valid UTF-8".to_string(), + })?) + } + + /// Opens a fresh in-memory voting database for tests and examples. + pub fn open_in_memory() -> Result { + Self::open(":memory:") + } + + /// Creates a voting round for the current wallet. + /// + /// The round id comes from `params.vote_round_id`. This call persists the + /// round parameters and is idempotent only at the caller layer; inserting an + /// already-existing `(wallet_id, round_id)` pair returns an error from the + /// underlying SQLite constraint. + pub fn create_round(&self, params: &RoundParams) -> Result<(), VotingError> { + crate::types::validate_round_params(params)?; + self.init_round(params, None) + } + + /// Loads one round summary for the current wallet. + /// + /// Returns `Ok(None)` when the round does not exist. Other database errors + /// are returned as [`VotingError::Internal`]. + pub fn round(&self, round_id: &str) -> Result, VotingError> { + let conn = self.conn(); + let wallet_id = self.wallet_id(); + let row = conn + .query_row( + "SELECT snapshot_height, created_at + FROM rounds + WHERE round_id = :round_id AND wallet_id = :wallet_id", + named_params! { ":round_id": round_id, ":wallet_id": wallet_id }, + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), + ) + .optional() + .map_err(|e| VotingError::Internal { + message: format!("failed to load round {round_id}: {e}"), + })?; + + let Some((snapshot_height, created_at)) = row else { + return Ok(None); + }; + + let bundle_count = queries::get_bundle_count(&conn, round_id, &wallet_id)?; + let eligible_weight = round_eligible_weight(&conn, round_id, &wallet_id)?; + + Ok(Some(RoundInfo { + round_id: round_id.to_string(), + snapshot_height: snapshot_height as u64, + hotkey_address: None, + eligible_weight, + bundle_count, + created_at: created_at as u64, + })) + } + + /// Lists all rounds for the current wallet in newest-first order. + pub fn rounds(&self) -> Result, VotingError> { + self.list_rounds()? + .into_iter() + .map(|summary| { + self.round(&summary.round_id)? + .ok_or_else(|| VotingError::Internal { + message: format!("round disappeared while listing: {}", summary.round_id), + }) + }) + .collect() + } + + /// Deletes all persisted state for one round in the current wallet scope. + pub fn delete_round(&self, round_id: &str) -> Result<(), VotingError> { + self.clear_round(round_id) + } + + /// Creates bundle rows for `notes`, or validates existing bundle rows. + /// + /// The note ordering and weight quantization are the canonical library + /// policy. On first call, surviving bundles are persisted. On later calls, + /// the same notes must reproduce the stored bundle identities. + pub fn ensure_bundles( + &self, + round_id: &str, + notes: &[NoteInfo], + ) -> Result { + crate::types::validate_notes_for_round(notes)?; + let plan = chunk_notes(notes); + let expected_count = plan.bundles.len() as u32; + let existing_count = self.get_bundle_count(round_id)?; + + if existing_count == 0 { + let (bundle_count, eligible_weight) = self.setup_bundles(round_id, notes)?; + return Ok(BundleLayout { + bundle_count, + eligible_weight, + dropped_count: plan.dropped_count as u32, + }); + } + + if existing_count != expected_count { + return Err(VotingError::InvalidInput { + message: format!( + "existing bundle count {existing_count} does not match planned bundle count {expected_count}" + ), + }); + } + + let conn = self.conn(); + let wallet_id = self.wallet_id(); + for (bundle_index, bundle_notes) in plan.bundles.iter().enumerate() { + queries::require_bundle_notes( + &conn, + round_id, + &wallet_id, + bundle_index as u32, + bundle_notes, + )?; + } + + Ok(BundleLayout { + bundle_count: expected_count, + eligible_weight: plan.eligible_weight, + dropped_count: plan.dropped_count as u32, + }) + } +} + +fn round_eligible_weight( + conn: &rusqlite::Connection, + round_id: &str, + wallet_id: &str, +) -> Result, VotingError> { + let total: Option = conn + .query_row( + "SELECT SUM((total_note_value / :ballot_divisor) * :ballot_divisor) + FROM bundles + WHERE round_id = :round_id AND wallet_id = :wallet_id", + named_params! { + ":round_id": round_id, + ":wallet_id": wallet_id, + ":ballot_divisor": crate::governance::BALLOT_DIVISOR as i64, + }, + |row| row.get(0), + ) + .map_err(|e| VotingError::Internal { + message: format!("failed to calculate round eligible weight: {e}"), + })?; + + Ok(total.map(|v| v as u64)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const ROUND_ID: &str = "0101010101010101010101010101010101010101010101010101010101010101"; + + fn test_db(wallet_id: &str) -> VotingDb { + let db = VotingDb::open_in_memory().unwrap(); + db.set_wallet_id(wallet_id); + db.create_round(&round_params()).unwrap(); + db + } + + fn round_params() -> RoundParams { + RoundParams { + vote_round_id: ROUND_ID.to_string(), + snapshot_height: 1000, + ea_pk: vec![0xEA; 32], + nc_root: vec![0xAA; 32], + nullifier_imt_root: vec![0xBB; 32], + } + } + + fn note(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(), + } + } + + #[test] + fn ensure_bundles_creates_and_validates_idempotently() { + let db = test_db("wallet-a"); + let notes = vec![note(0, crate::governance::BALLOT_DIVISOR)]; + + let created = db.ensure_bundles(ROUND_ID, ¬es).unwrap(); + let reused = db.ensure_bundles(ROUND_ID, ¬es).unwrap(); + + assert_eq!(created.bundle_count, 1); + assert_eq!(created.eligible_weight, crate::governance::BALLOT_DIVISOR); + assert_eq!(reused, created); + } + + #[test] + fn ensure_bundles_rejects_changed_existing_bundle_identity() { + let db = test_db("wallet-b"); + db.ensure_bundles(ROUND_ID, &[note(0, crate::governance::BALLOT_DIVISOR)]) + .unwrap(); + + let mut substituted = note(0, crate::governance::BALLOT_DIVISOR); + substituted.nullifier[0] ^= 0x01; + + let err = db.ensure_bundles(ROUND_ID, &[substituted]).unwrap_err(); + + assert!(err.to_string().contains("note identity mismatch"), "{err}"); + } + + #[test] + fn round_reports_bundle_count_and_quantized_weight() { + let db = test_db("wallet-c"); + let notes = vec![ + note(0, crate::governance::BALLOT_DIVISOR + 1), + note(1, crate::governance::BALLOT_DIVISOR), + note(2, 1), + note(3, 1), + note(4, 1), + note(5, crate::governance::BALLOT_DIVISOR), + ]; + let layout = db.ensure_bundles(ROUND_ID, ¬es).unwrap(); + db.conn() + .execute( + "UPDATE bundles + SET total_note_value = ?1 + WHERE round_id = ?2 AND wallet_id = ?3 AND bundle_index = 0", + rusqlite::params![layout.eligible_weight as i64 + 1, ROUND_ID, "wallet-c"], + ) + .unwrap(); + + let round = db.round(ROUND_ID).unwrap().unwrap(); + + assert_eq!(round.bundle_count, layout.bundle_count); + assert_eq!(round.eligible_weight, Some(layout.eligible_weight)); + } +} diff --git a/zcash_voting/src/storage/operations.rs b/zcash_voting/src/storage/operations.rs index 53781a20..ae0dea54 100644 --- a/zcash_voting/src/storage/operations.rs +++ b/zcash_voting/src/storage/operations.rs @@ -3,7 +3,10 @@ // imports below are only reachable from `#[cfg(test)]` code, which is fine // for `cargo test` but trips `unused_imports` on `cargo check`. Silence that // narrow case rather than fragment the imports along feature/test lines. -#![cfg_attr(not(feature = "client-pir"), allow(unused_imports, dead_code))] +#![cfg_attr( + not(any(feature = "pir", feature = "client-pir")), + allow(unused_imports, dead_code) +)] use std::collections::HashMap; @@ -29,7 +32,7 @@ use crate::types::{ }; /// Wallet-supplied inputs for shared delegation PCZT and PIR preparation. -#[cfg(feature = "client-pir")] +#[cfg(any(feature = "pir", feature = "client-pir"))] pub struct PrepareDelegationPirParams<'a> { pub round_id: &'a str, pub bundle_index: u32, @@ -46,7 +49,7 @@ pub struct PrepareDelegationPirParams<'a> { pub network_id: u32, } -#[cfg(feature = "client-pir")] +#[cfg(any(feature = "pir", feature = "client-pir"))] fn nullifier_bytes_to_base(bytes: &[u8], label: &str) -> Result { let nf_bytes: [u8; 32] = bytes.try_into().map_err(|_| VotingError::Internal { message: format!("{label} nullifier must be 32 bytes, got {}", bytes.len()), @@ -56,7 +59,7 @@ fn nullifier_bytes_to_base(bytes: &[u8], label: &str) -> Result], @@ -96,7 +99,7 @@ fn delegation_nullifier_targets( Ok(targets) } -#[cfg(feature = "client-pir")] +#[cfg(any(feature = "pir", feature = "client-pir"))] fn nullifier_imt_root_to_base(bytes: &[u8]) -> Result { let root_bytes: [u8; 32] = bytes.try_into().map_err(|_| VotingError::Internal { message: format!("nullifier_imt_root must be 32 bytes, got {}", bytes.len()), @@ -108,7 +111,7 @@ fn nullifier_imt_root_to_base(bytes: &[u8]) -> Result /// Derive padded-slot nullifiers with the same synthetic padding points used by /// the delegation circuit builder. -#[cfg(feature = "client-pir")] +#[cfg(any(feature = "pir", feature = "client-pir"))] fn padded_nullifiers_for_circuit( notes: &[NoteInfo], padded_secrets: &[(Vec, Vec)], @@ -188,7 +191,7 @@ fn padded_nullifiers_for_circuit( Ok(out) } -#[cfg(feature = "client-pir")] +#[cfg(any(feature = "pir", feature = "client-pir"))] fn precomputed_randomness_from_stored( notes_len: usize, padded_secrets: &[(Vec, Vec)], @@ -599,7 +602,7 @@ impl VotingDb { /// 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`). - #[cfg(feature = "client-pir")] + #[cfg(any(feature = "pir", feature = "client-pir"))] pub fn precompute_delegation_pir( &self, round_id: &str, @@ -688,7 +691,7 @@ impl VotingDb { /// Build the governance PCZT for one eligible delegation bundle and /// precompute the PIR-backed IMT proofs required by delegation proving. - #[cfg(feature = "client-pir")] + #[cfg(any(feature = "pir", feature = "client-pir"))] pub fn prepare_delegation_pir( &self, params: PrepareDelegationPirParams<'_>, @@ -756,7 +759,7 @@ impl VotingDb { /// For padded notes (< 5 real notes), the prover fetches proofs internally via PIR. /// /// Stores the proof result and advances phase to `DelegationProved`. - #[cfg(feature = "client-pir")] + #[cfg(any(feature = "pir", feature = "client-pir"))] pub fn build_and_prove_delegation( &self, round_id: &str, @@ -1637,7 +1640,7 @@ mod tests { assert_eq!(hotkey.public_key.len(), 32); } - #[cfg(feature = "client-pir")] + #[cfg(any(feature = "pir", feature = "client-pir"))] #[test] fn test_precomputed_randomness_requires_stored_rseeds() { let err = match precomputed_randomness_from_stored(5, &[], &[], &[0x11; 32], 0) { @@ -1648,7 +1651,7 @@ mod tests { assert!(err.to_string().contains("rseed_signed"), "{err}"); } - #[cfg(feature = "client-pir")] + #[cfg(any(feature = "pir", feature = "client-pir"))] #[test] fn test_padded_pir_nullifiers_match_persisted_dummy_nullifiers() { use orchard::{ @@ -1727,7 +1730,7 @@ mod tests { assert_eq!(pir_nullifiers, result.dummy_nullifiers); } - #[cfg(feature = "client-pir")] + #[cfg(any(feature = "pir", feature = "client-pir"))] #[test] fn test_prepare_delegation_pir_builds_pczt_and_reuses_cached_pir_proofs() { use orchard::{ diff --git a/zcash_voting/src/storage/queries.rs b/zcash_voting/src/storage/queries.rs index cc6ec6e3..9b3a5630 100644 --- a/zcash_voting/src/storage/queries.rs +++ b/zcash_voting/src/storage/queries.rs @@ -1127,7 +1127,7 @@ pub fn store_proof_result_fields( } /// Persist proof public inputs and compare the proof VAN against the stored PCZT VAN. -#[cfg_attr(not(feature = "client-pir"), allow(dead_code))] +#[cfg_attr(not(any(feature = "pir", feature = "client-pir")), allow(dead_code))] pub(crate) fn store_proof_result_fields_with_van_comm( conn: &Connection, round_id: &str, diff --git a/zcash_voting/src/transport.rs b/zcash_voting/src/transport.rs new file mode 100644 index 00000000..ae96896e --- /dev/null +++ b/zcash_voting/src/transport.rs @@ -0,0 +1,9 @@ +//! Built-in HTTP transports for optional client features. + +#[cfg(any( + feature = "pir", + feature = "tree-sync", + feature = "client-pir", + feature = "client-tree-sync" +))] +pub use crate::http_transport::HyperTransport; diff --git a/zcash_voting/src/types.rs b/zcash_voting/src/types.rs index 53a483ce..9eb0d469 100644 --- a/zcash_voting/src/types.rs +++ b/zcash_voting/src/types.rs @@ -15,6 +15,38 @@ pub enum VotingError { Internal { message: String }, } +/// Zcash network selector used by wallet-facing voting APIs. +/// +/// The enum replaces the historical `network_id` convention, where `0` +/// meant testnet and `1` meant mainnet. Use [`Network::id`] only when calling +/// legacy internals that still take the numeric representation. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Network { + Testnet, + Mainnet, +} + +impl Network { + /// Returns the legacy numeric network id used by existing proof builders. + pub fn id(self) -> u32 { + match self { + Self::Testnet => 0, + Self::Mainnet => 1, + } + } + + /// Converts a legacy network id into a typed network selector. + pub fn from_id(id: u32) -> Result { + match id { + 0 => Ok(Self::Testnet), + 1 => Ok(Self::Mainnet), + _ => Err(VotingError::InvalidInput { + message: format!("network_id must be 0 (testnet) or 1 (mainnet), got {id}"), + }), + } + } +} + /// Unwrap a `CtOption`, returning a `VotingError` on `None`. pub fn ct_option_to_result(opt: CtOption, msg: &str) -> Result { if opt.is_some().into() { @@ -408,6 +440,14 @@ pub trait ProofProgressReporter: Send + Sync { fn on_progress(&self, progress: f64); } +/// Progress callback used by the stable public API. +/// +/// This is an alias for the legacy proof-progress trait so existing FFI +/// adapters continue to work while the public naming is simplified. +pub trait ProgressReporter: ProofProgressReporter {} + +impl ProgressReporter for T where T: ProofProgressReporter + ?Sized {} + /// No-op progress reporter for contexts where progress isn't observed. pub struct NoopProgressReporter; diff --git a/zcash_voting/src/zkp1.rs b/zcash_voting/src/zkp1.rs index 3cf1c390..b0d5920b 100644 --- a/zcash_voting/src/zkp1.rs +++ b/zcash_voting/src/zkp1.rs @@ -35,7 +35,7 @@ use crate::types::{ /// Convert an IMT proof from the PIR data crate into the circuit-crate `ImtProofData`. /// Both use the K=2 punctured-range format with `nf_bounds = [nf_lo, nf_mid, nf_hi]`. -#[cfg(feature = "client-pir")] +#[cfg(any(feature = "pir", feature = "client-pir"))] pub fn convert_pir_proof(pir: pir_client::ImtProofData) -> ImtProofData { ImtProofData { root: pir.root, @@ -49,7 +49,7 @@ fn base_hex(value: pallas::Base) -> String { hex::encode(value.to_repr()) } -#[cfg(feature = "client-pir")] +#[cfg(any(feature = "pir", feature = "client-pir"))] fn validate_pir_proof_raw( proof: &pir_client::ImtProofData, nullifier: pallas::Base, @@ -71,7 +71,7 @@ fn validate_pir_proof_raw( Ok(()) } -#[cfg(feature = "client-pir")] +#[cfg(any(feature = "pir", feature = "client-pir"))] pub fn validate_and_convert_pir_proof( proof: pir_client::ImtProofData, nullifier: pallas::Base, @@ -554,7 +554,7 @@ mod tests { } } - #[cfg(feature = "client-pir")] + #[cfg(any(feature = "pir", feature = "client-pir"))] fn raw_pir_proof(proof: ImtProofData) -> pir_client::ImtProofData { pir_client::ImtProofData { root: proof.root, @@ -564,7 +564,7 @@ mod tests { } } - #[cfg(feature = "client-pir")] + #[cfg(any(feature = "pir", feature = "client-pir"))] #[test] fn validate_and_convert_pir_proof_accepts_valid_proof() { let imt = SpacedLeafImtProvider::new(); @@ -577,7 +577,7 @@ mod tests { assert_eq!(converted.root, root); } - #[cfg(feature = "client-pir")] + #[cfg(any(feature = "pir", feature = "client-pir"))] #[test] fn validate_and_convert_pir_proof_rejects_unverified_path() { let imt = SpacedLeafImtProvider::new(); @@ -594,7 +594,7 @@ mod tests { ); } - #[cfg(feature = "client-pir")] + #[cfg(any(feature = "pir", feature = "client-pir"))] #[test] fn validate_and_convert_pir_proof_rejects_wrong_root() { let imt = SpacedLeafImtProvider::new();