> For the complete documentation index, see [llms.txt](https://valargroup.gitbook.io/shielded-vote-docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://valargroup.gitbook.io/shielded-vote-docs/wallet-integration/guide.md).

# Integration Guide

> This guide documents integration against **`zcash_voting` 3.1.0-rc.13** (verified against `valargroup/zcash_voting` `main` @ `1de427b`, 2026-08-30). The crate's stage-oriented wallet API is stable through `zcash_voting::prelude`; snippets below are adapted from the compile-checked [`wallet-example`](https://github.com/valargroup/zcash_voting/tree/main/wallet-example/src) crate in that repository, which is the canonical integration reference.

Reference material:

* Reference SDK (Rust): [`zcash_voting`](https://crates.io/crates/zcash_voting) on crates.io, source at [`valargroup/zcash_voting`](https://github.com/valargroup/zcash_voting).
* Canonical integration examples: [`wallet-example`](https://github.com/valargroup/zcash_voting/tree/main/wallet-example/src) crate (compile-checked, per-flow modules).
* Reference implementation (iOS): [`valargroup/zodl-ios` branch `shielded-vote`](https://github.com/valargroup/zodl-ios/tree/shielded-vote).
* Draft ZIP specification: [Shielded Voting Wallet API](https://github.com/zcash/zips/pull/1244) (ZIP number TBD; draft currently open as a pull request against zcash/zips).

***

## 1. Overview and architecture

Shielded on-chain voting lets holders of Zcash Orchard notes cast encrypted ballots on proposals without revealing which notes, which addresses, or which individual votes they are contributing. Vote weights are tallied in aggregate after the round ends.

A wallet interacts with three services during a round:

| Service                                    | Repo                                                                                              | Role                                                                                                                                                                                                               |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **vote-sdk** (`svoted` chain + helper API) | [valargroup/vote-sdk](https://github.com/valargroup/vote-sdk)                                     | Runs the voting chain. Serves `/shielded-vote/v1/*`, the REST API wallets use to query rounds, submit delegations and ballots, relay shares, and read tallies.                                                     |
| **vote-nullifier-pir**                     | [valargroup/vote-nullifier-pir](https://github.com/valargroup/vote-nullifier-pir)                 | YPIR server that returns non-membership proofs for Orchard nullifiers without revealing which nullifier was queried.                                                                                               |
| **token-holder-voting-config**             | [valargroup/token-holder-voting-config](https://github.com/valargroup/token-holder-voting-config) | Per-round service-discovery JSON (vote-server URLs, PIR URLs, round metadata). This repo is one reference publisher; wallet vendors are expected to bundle or mirror their own copy as part of shipping a release. |

### Division of responsibility

Since the `zcash_voting` 3.x API, the SDK owns considerably more of the flow than early integrations did. The dividing line:

**The SDK owns:** config parsing/authentication and switch decisions, round-state persistence (a SQLite "voting sidecar" database), note bundling and privacy-trim policy, PIR nullifier proofs, delegation PCZT construction and ZKP1 proving, ZKP2 proving and cast-vote signing, helper-share planning/journaled delivery/confirmation tracking, wire-JSON serialization, and crash-recovery planning.

**The wallet owns:** seed material and signing (the crate never holds the seed — see §7), all networking transports (config fetch, lightwalletd, vote-chain POSTs, PIR and helper transports can be routed through the wallet's own stack, Tor, or a proxy), chain submission and confirmation polling, timers/cancellation, and UI.

### Protocol vocabulary

* **Vote round.** A time-bounded session with a fixed proposal set, a Zcash block-height snapshot, and a deadline.
* **Voting hotkey.** A Pallas keypair the wallet derives from a 32-byte voting seed it owns (`generate_random_voting_hotkey` in the SDK). The voting seed is app-managed, stored in the wallet's secure storage alongside the user's other credentials. The user's Orchard spend key is used once during delegation (§7) to bind their notes to the hotkey; every vote commitment after delegation is signed by the hotkey alone, so the voting chain never receives the user's Orchard spend key.
* **Bundle.** The SDK groups a holder's eligible notes into one or more delegation bundles (`BundleLayout`), each of which is delegated and voted independently. Bundling policy (`BundlePolicy`) trims a low-value note tail to reduce the number of delegation submissions a concentrated holder emits (§5).
* **Delegation.** The act of transferring voting authority from the user's Orchard spend key to a hotkey. The wallet proves ownership of unspent Orchard notes at the snapshot height and registers a **vote authority note (VAN)** that binds the delegated weight to the hotkey on the vote commitment tree.
* **Vote authority note (VAN).** A Poseidon commitment appended to the vote commitment tree during delegation. The VAN is consumed (its nullifier recorded) when the hotkey casts a ballot, and a fresh VAN is created for the next ballot.
* **Vote commitment tree.** An append-only Poseidon Merkle tree that records VANs and vote commitments as leaves. The chain publishes the tree root at each block height; wallets sync it via `precompute::sync_vote_tree` (§6).
* **Share.** One of 16 encrypted fragments the vote is split into before being submitted (`VOTE_COMMITMENT_SHARE_COUNT`). Each share is ElGamal-encrypted under the Election Authority public key and delivered to helper servers (§10). Wallets don't need to understand the split-and-reconstruct math; `zcash_voting` produces the payloads and owns delivery journaling.
* **Election Authority (EA).** A threshold of validators collectively holding the decryption key. The EA only decrypts aggregate tallies; it never sees individual votes.

### ZKPs at a glance

| #    | Name                       | Generated by | Proves                                                          |
| ---- | -------------------------- | ------------ | --------------------------------------------------------------- |
| ZKP1 | Delegation / balance proof | wallet       | Holder owns notes at snapshot and correctly binds them to a VAN |
| ZKP2 | Vote commitment            | wallet       | VAN owner cast a specific `(proposal_id, vote_decision)`        |
| ZKP3 | Share reveal               | helper       | Submitted share matches a committed share set                   |

***

## 2. Prerequisites

### What to install

A Rust wallet adds one crate:

```toml
[dependencies]
zcash_voting = "3.1.0-rc.13"
```

`zcash_voting` requires **Rust 1.91+** and now covers the whole client-side surface: config resolution, round/bundle persistence, ZKP1/ZKP2 proving, hotkey derivation, PIR (the `pir-client` crate is an internal dependency — wallets no longer depend on it directly), vote-commitment-tree sync, helper-share delivery, and recovery planning. Import everything from the prelude:

```rust
use zcash_voting::prelude::*;
```

The prelude is documented as "stable imports for wallet SDK integrations" — prefer it over internal module paths.

### Proving-stack backend

The crate builds against the **`zakura`** proving stack by default, and that is what wallet integrations should use — a plain `zcash_voting = "..."` dependency with default features is the supported path.

A custom **`lrz`** feature exists for wallets on the Ironwood / NU6.3 dependency stack (`orchard 0.15` with `unstable-voting-circuits`, `pczt 0.9.2`, `zcash_client_sqlite 0.22.0-rc.7`, `voting-circuits 0.11.2`); opt in with `default-features = false, features = ["lrz"]`. The two features are mutually exclusive. Only reach for `lrz` if your wallet's librustzcash generation requires it; `Cargo.toml` in the SDK repo is the source of truth for the exact dependency versions each feature pulls.

Non-Rust wallets: see §17 for the iOS reference integration via [`zcash-swift-wallet-sdk`](https://github.com/valargroup/zcash-swift-wallet-sdk/tree/shielded-vote), which binds this same crate over a C FFI.

***

## 3. Vote configuration discovery

### What a vote configuration is

A **vote configuration** is the document a wallet resolves first, before any user action. It tells the wallet which rounds exist, which servers to talk to (`vote_servers[]`, `pir_endpoints[]`), the PIR layout parameters, and which protocol versions the servers run.

Configuration now resolves in two stages, both owned by `zcash_voting::config`:

1. A **static config** — the trust anchor the vendor pins in the app (a URL plus an optional hash pin, parsed by `PinnedConfigSource::parse`). It names one or more **dynamic config mirrors**.
2. A **dynamic config** — fetched from the first healthy mirror; carries the per-round data and is authenticated against the static anchor.

The SDK is transport-agnostic: the wallet fetches bytes with its own HTTP stack and passes them to `resolve_static_voting_config` / `resolve_dynamic_voting_config_over_mirrors`. Manual JSON validation by the wallet is no longer part of the integration — the crate performs schema validation, authentication, and mirror-failover preference rules internally.

### Resolving (adapted from `wallet-example/src/example_config.rs`)

```rust
use zcash_voting::config::{
    decide_config_switch, resolve_dynamic_voting_config_over_mirrors,
    resolve_static_voting_config, PinnedConfigSource, DYNAMIC_MIRROR_FETCH_TIMEOUT,
};
use zcash_voting::wire::{ResolveVotingConfigOptions, ResolvedVotingConfig};

// 1. Fetch the static trust anchor with the wallet's own transport.
let static_url = PinnedConfigSource::parse(source)?.url;
let static_bytes = fetch_bytes(&static_url).await?;
let resolved_static = resolve_static_voting_config(source, &static_bytes)?;

// 2. Walk the ordered dynamic mirrors; stops at the first that resolves
//    with authenticated rounds, so a healthy primary costs one request.
let (resolved, skipped_mirrors) = resolve_dynamic_voting_config_over_mirrors(
    resolved_static,
    DYNAMIC_MIRROR_FETCH_TIMEOUT,
    ResolveVotingConfigOptions::default(),
    |url| async move { fetch_bytes(&url).await.map_err(|e| format!("{e:#}")) },
)
.await?;
```

### Config switches

Persist a `ResolvedVotingConfigSummary` between runs and classify each new resolution with `decide_config_switch(previous_summary, next_summary)`. The returned `ConfigSwitchDecision` tells the wallet how much local voting state the move invalidates (e.g. the publisher rotated to a different operational service vs. a benign refresh). `wallet-example/src/example_config.rs` shows the full store/compare/persist cycle (`StoredConfigState`, `ConfigSwitchOutcome`).

### Binding round parameters from authenticated config

When starting a session, build `VotingRoundParams` with `ResolvedVotingConfig::trusted_voting_round_params(round_id, snapshot_height, nc_root, nullifier_imt_root)`: dynamic fields (`snapshot_height`, roots) come from the chain (§4), but **`ea_pk` is always bound from the authenticated config**, never from server responses. This is what stops a compromised vote server from substituting its own encryption key.

### Where the wallet gets the static source

The wallet vendor pins one static config source and ships their build against it. The end user never sees the URL and never configures one. Typical options: bundle the static JSON in the app, mirror it on vendor-controlled infrastructure, or point at a shared reference publisher you explicitly trust.

[valargroup/token-holder-voting-config](https://github.com/valargroup/token-holder-voting-config) is provided as a reference publisher for early integrators:

* Staging: `https://valargroup.github.io/token-holder-voting-config/staging/voting-config.json`
* Production: `https://valargroup.github.io/token-holder-voting-config/production/voting-config.json`

The hosting location is not a trust root on its own; use the hash pin in the static source where possible.

### Reference

* `zcash_voting/examples/config_fetcher.rs` — runnable CLI (`resolve <source>`, `check-switch <state-path> <source>`).
* Draft §Vote Discovery, §Distribution.
* Current publisher: [token-holder-voting-config/README.md](https://github.com/valargroup/token-holder-voting-config#readme).

***

## 4. Fetch the active round from the chain

### Endpoint

```
GET /shielded-vote/v1/rounds/active
```

### Response

JSON object with a `round` field (`null` or empty if no round is active). The `VoteRound` structure:

| Field                                                  | Type              | Notes                                                          |
| ------------------------------------------------------ | ----------------- | -------------------------------------------------------------- |
| `vote_round_id`                                        | base64 (32 bytes) | Must match a round authenticated in the resolved config.       |
| `snapshot_height`                                      | uint64            | Orchard snapshot.                                              |
| `snapshot_blockhash`                                   | base64 (32 bytes) | Zcash block hash at snapshot.                                  |
| `proposals_hash`                                       | base64 (32 bytes) | SHA-256 over canonical proposals.                              |
| `vote_end_time`                                        | uint64            | Unix seconds.                                                  |
| `nullifier_imt_root`                                   | base64 (32 bytes) | Root of the nullifier non-membership tree (input to ZKP1).     |
| `nc_root`                                              | base64 (32 bytes) | Orchard note commitment tree root at snapshot (input to ZKP1). |
| `status`                                               | uint32            | `1=ACTIVE`, `2=TALLYING`, `3=FINALIZED`, `4=PENDING`.          |
| `ea_pk`                                                | base64 (32 bytes) | Compressed Pallas; ElGamal public key for share encryption.    |
| `proposals[]`                                          | array             | Mirror of config. Verify `proposals_hash` matches.             |
| `title`, `description`, `creator`, `created_at_height` |                   | Informational.                                                 |

The response may also carry ceremony fields (validator keys, ECIES payloads). They are for validator coordination; wallets can ignore them.

### What the wallet does next

1. Verify `status == 1 (ACTIVE)`; otherwise abort.
2. Verify the round is one of the resolved config's authenticated rounds.
3. Build `VotingRoundParams` via `trusted_voting_round_params(...)` (§3) — server-provided `snapshot_height`, `nc_root`, `nullifier_imt_root`, but config-bound `ea_pk`.

***

## 5. Round setup: sidecar database, bundles, hotkey

### The voting sidecar

All round state — bundles, witnesses, PIR proof cache, recovery journals, share tracking — persists in a SQLite database owned by the crate. Open it once alongside the wallet's own database:

```rust
let voting_db = VotingDb::open_wallet_sidecar(&wallet_db_path)?;
// or VotingDb::open_path(...) / VotingDb::open_in_memory() in tests
```

Create or load the round from the trusted params, then materialize its bundles:

```rust
voting_db.ensure_round(&round_params, round_name)?;
voting_db.ensure_bundles_with_policy(&round_id, &notes, bundle_policy)?;
```

### Bundling policy and privacy trim

`BundlePolicy` controls how eligible notes are grouped into delegation bundles. Planning trims the low-value bundle tail so a concentrated holder emits fewer delegation submissions — bounded by the smaller of 1% of selected note value and 1,000 ZEC by default. The `PrivacyTrim` result reports the raw note value excluded. Related constants and helpers: `MINIMUM_VOTING_WEIGHT_ZATOSHI`, `MINIMUM_VOTING_NOTE_COUNT`, `note_bundles_with_policy`, `quantized_bundle_weight`, `voting_power_with_policy`.

Use the **same** `BundlePolicy` everywhere in a round; once a plan is stored, `voting_db.effective_bundle_policy(round_id, policy)` returns the persisted policy so later calls can't diverge.

### Voting hotkey

```rust
let hotkey: VotingHotkey = generate_random_voting_hotkey()?;
```

Store the hotkey secret (`VOTING_HOTKEY_STORED_SECRET_LEN` bytes) in the wallet's secure storage, keyed per round. The hotkey signs every cast-vote; the Orchard spend key is only touched once, at delegation signing (§7).

### Recording ballot intent

Before starting any cast-vote flow, durably record the user's intent in the session journal: `Decision::Choice` with the proposal's declared option count, or `Decision::Skipped` for proposals the user intentionally leaves blank. This is what makes crash recovery (§11) able to distinguish "not voted yet" from "chose to skip".

***

## 6. Precompute: PIR proofs, tree sync, witnesses

### Why PIR

ZKP1 proves, among other things, that the wallet's Orchard note nullifiers are **not** present in the global nullifier set at the snapshot height (i.e. the notes are unspent). The nullifier set has tens of millions of entries; the wallet cannot download it. Instead the wallet uses Private Information Retrieval to fetch a compact Merkle non-membership path without revealing which nullifier it queried.

### Connecting

PIR access is built into the SDK. The connection threads the resolved config's `pir_layout` through the handshake so wallets never hardcode depth, tier-split, or YPIR degree constants:

```rust
use std::sync::Arc;
use zcash_voting::{connect_pir_blocking, HyperTransport};

let pir_client = connect_pir_blocking(
    resolved.pir_layout,          // from the resolved dynamic config (§3)
    pir_server_url,               // a pir_endpoints[] entry; see select_pir_endpoint
    Arc::new(HyperTransport::new()),
)?;
```

An async `connect_pir` variant exists; `select_pir_endpoint` helps choose among advertised endpoints. The transport argument is caller-owned, so a wallet can substitute its own (e.g. Tor-routed) implementation of the PIR transport.

Practical notes: a single-nullifier query involves megabyte-scale upload — plan for that on metered or slow connections. The client library enforces the anti-oracle query discipline internally.

### Warming the PIR proof cache early

`precompute_pir_proofs` warms nullifier proofs **in the background before any round, bundle, or hotkey exists**, keyed by network and served IMT root. Run it as soon as the wallet knows its selected notes; pass the same `BundlePolicy` the wallet will later use so a dust tail is not PIR-queried. The delegation prove path reads the same cache, so proofs warmed here are never refetched at proving time.

```rust
let report = precompute_pir_proofs(&voting_db, &notes, bundle_policy, network, &pir_client)?;
```

* `validate_cached_pir_proofs` checks warmed proofs against a round's `nullifier_imt_root` offline, to detect a stale snapshot before proving.
* Once the wallet is scanned through the round's snapshot height and the round exists, `precompute_snapshot_bundles` locks in the canonical bundle plan and warms PIR for real notes plus padded-slot nullifiers. Retries are idempotent.
* Snapshots coexist in the cache; leftover roots are unused. Warmup prunes cache rows older than four weeks.

### Vote-commitment-tree sync and witnesses

The vote commitment tree (§1) is synced through the SDK; the wallet supplies only the vote-chain node URL:

```rust
// From wallet-example/src/example_vote.rs
let anchor_height = sync_vote_tree(&voting_db, round_id, vote_node_url)?;
let witness: VanWitness = van_witness(&voting_db, round_id, bundle_index, anchor_height)?;
```

Sync is incremental and persisted in the sidecar. It is only needed at two moments: after a delegation confirms (to witness the new VAN) and after each cast-vote confirms (the new VAN feeds the next ballot; the vote-commitment position feeds share payloads). `reset_vote_tree` discards local tree state if it diverges. Note witnesses for delegation are produced by `note_witnesses` / handled inside `prepare_delegation_bundle` (§7).

***

## 7. Delegation (ZKP1)

Delegation is a staged lifecycle around one `PreparedDelegationBundle` per bundle: **prepare → precompute → setup → sign → prove → assemble → submit → confirm**. All stages persist in the sidecar, so an interrupted flow resumes without repeating work.

### 7.1 Prepare

`gather_delegation_lwd_inputs` is the only step that touches lightwalletd; it resolves chain anchors for the round. `prepare_delegation_bundle` then selects and validates the bundle's notes from the wallet database and generates witnesses:

```rust
// From wallet-example/src/example_delegation.rs
let lwd = gather_delegation_lwd_inputs(ResolveDelegationLwdParams {
    lightwalletd_url,
    network,
    round_params,
    round_name,
}).await?;

let prepared: PreparedDelegationBundle = prepare_delegation_bundle(
    &voting_db,
    &wallet_db,        // the wallet's zcash_client_sqlite::WalletDb
    PrepareDelegationBundleParams {
        lwd,
        session_json,
        account_uuid,
        voting_hotkey: &hotkey,
        bundle_index,
        bundle_policy,
    },
)?;
```

### 7.2 Precompute

```rust
prepared.precompute(&voting_db, &wallet_db, &pir_client)?;
```

Stores note witnesses and PIR-backed nullifier data for the bundle. Reads the warmed cache from §6; only per-bundle padded-slot nullifiers still need a live fetch.

### 7.3 Sign — the wallet boundary

The crate **never holds the seed**. It hands the wallet a `DelegationSigningRequest` (randomizer `alpha`, ZIP-244 `sighash`, seed fingerprint, account index) and takes back only 64 signature bytes.

**Software wallet:**

```rust
let setup = prepared.setup(&voting_db, &progress)?;          // builds the governance PCZT
let request = prepared.signing_request(&voting_db)?;

// Wallet-owned signing code: derive the account spending key from the seed
// identified by request.seed_fingerprint, randomize by request.alpha, sign
// request.sighash with RedPallas, return (sig, sighash). See
// example_sign_delegation_request in wallet-example/src/example_delegation.rs.
let (sig, sighash) = wallet_sign(seed, request)?;

prepared.prove(&voting_db, &pir_client, &progress)?;         // ZKP1
let submission: DelegationSubmission = prepared
    .signed_bundle(&voting_db, setup.pczt_bytes, PreparedSigner::signature(sig, sighash))?
    .submission;
```

**Hardware wallet (Keystone):**

```rust
let keystone_request = prepared.keystone_request(&voting_db, &progress)?;
// -> show keystone_request's redacted PCZT bytes to the device via QR;
//    the user reviews and signs on-device; scan the signed PCZT back.

prepared.prove(&voting_db, &pir_client, &progress)?;
let sig = spend_auth_signature(&signed_pczt_bytes, keystone_request.action_index as usize)?;
let signer = PreparedSigner::signature_from_bytes(&sig, &keystone_request.pczt_sighash)?;
let submission = prepared.signed_bundle(&voting_db, Vec::new(), signer)?.submission;
```

The PCZT signed here is **TX1**, a consensus-shaped Zcash transaction used only to obtain the ZIP-244 SpendAuth signature. It is *not* the vote-chain delegation submission and must never be broadcast to the Zcash network. The full TX1 wire specification, with software and Keystone examples, is in [Delegation signing transaction (TX1)](https://github.com/valargroup/zcash_voting/blob/main/docs/delegation-signing-transaction.md) — this guide does not duplicate it.

A multi-bundle delegation means one QR round-trip per bundle. Don't paper over that by caching the user's seed in-process to sign the rest locally; that erases the isolation the user installed the hardware wallet for.

### 7.4 Submit

```rust
let body: String = submission.to_wire_json()?;   // canonical request JSON
```

```
POST /shielded-vote/v1/delegate-vote
Content-Type: application/json
```

Use an HTTP client timeout of at least **120 s**: the chain runs ZKP verification inside CheckTx, matching the server-side limit in vote-sdk's `api/handler.go`. Response: `{"tx_hash": "<hex>", "code": 0, "log": ""}`.

Poll for confirmation:

```
GET /shielded-vote/v1/tx/{hash}
```

Returns `{height, code, log, events}`. Confirmed when `height` is non-empty and `code == 0`; 404 while pending; 422 if the TX was included but failed validation.

### 7.5 Confirm

Feed the confirmed transaction's events back so the crate records the tx hash and VAN tree position atomically:

```rust
let confirmation: DelegationConfirmation =
    confirm_delegation_submission(&voting_db, round_id, bundle_index, tx_hash, &events)?;
```

`confirmation::*` parses the chain's `leaf_index` events; the wallet should not scrape tree positions out of logs itself.

### Reference

* `MsgDelegateVote` in [proto/svote/v1/tx.proto](https://github.com/valargroup/vote-sdk/blob/main/proto/svote/v1/tx.proto).
* Draft §Delegation Transaction.

***

## 8. Casting votes (ZKP2)

After delegation confirms, derive the VAN witness (§6) and commit the ballot. Two submission shapes exist; pick one per bundle:

### 8.1 Single proposal: `CommittedVote::commit`

```rust
// From wallet-example/src/example_vote.rs
let committed: CommittedVote = CommittedVote::commit(
    &voting_db,
    round_id,
    bundle_index,
    &draft,                          // DraftVote: proposal_id + choice
    &van_witness,
    VoteSigner::hotkey(&voting_hotkey),
    &progress,
)?;

let body = committed.signed_commitment(&voting_db)?.to_wire_json()?;
```

```
POST /shielded-vote/v1/cast-vote
```

`commit` builds ZKP2, signs with the hotkey, and persists a canonical `VoteRecoveryBundle` before returning, so a crash after this point never rebuilds the proof. Repeated calls for the same `(round_id, bundle_index, proposal_id)` return the persisted material when the stored draft matches.

After the chain confirms, record atomically:

```rust
confirm_vote_submission(&voting_db, /* round, bundle, tx hash, events */)?;
```

(or `record_vote_submission` + `record_vc_position` if the wallet tracks events itself).

### 8.2 Multiple proposals: atomic batch

Multi-proposal ballots are submitted as **one atomic, ordered transaction**. This replaces the earlier serialized one-proposal-at-a-time flow — a wallet no longer submits, confirms, and re-syncs per proposal.

```rust
let batch: SignedVoteBatch = commit_atomic_vote_batch(
    &voting_db,
    round_id,
    bundle_index,
    &drafts,                         // one DraftVote per proposal, ordered
    &van_witness,
    VoteSigner::hotkey(&voting_hotkey),
    &progress,
)?;

let body = batch.batch_json.clone(); // canonical request body — submit as-is
```

```
POST /shielded-vote/v1/cast-vote-batch
```

Rules that matter:

* Submit `batch_json` **exactly once**, verbatim. The votes' order is part of the batch digest and VAN authority chain.
* **Never** submit an atomic batch's individual commitments through the singleton `cast-vote` endpoint — the distinct `SignedVoteBatch` vs `SignedVoteCommitments` result types exist to keep the two endpoints separate.
* Batch size is bounded by `MAX_VOTE_BATCH_ACTIONS`.
* After confirmation: `record_vote_batch_submission(...)` with the shared tx hash, then `confirm_vote_batch_submission(&voting_db, ..., &events)` to record all VC positions from the ordered events.

`commit_batch` (plural drafts, independent signatures) still exists as a **one-draft compatibility wrapper** for singleton submission; do not use it for multi-proposal ballots.

### What the chain checks

* Field sizes and ranges; `proposal_id` in `1..15`; chain-side option count validates the vote decision.
* The anchor root at `vote_comm_tree_anchor_height` matches a known checkpoint (the SDK picks the anchor from its tree sync).
* ZKP2 verifies, including `ea_pk` from the round.
* `r_vpk` verifies the RedPallas `vote_auth_sig` over the canonical sighash the chain recomputes.
* `van_nullifier` is unused in this round. Retrying an already-accepted submission surfaces a chain-side rejection, not a silent success — dedupe client-side via the recovery plan (§11) rather than blind retry.

### Reference

* `MsgCastVote`, `MsgCastVoteBatch` in [proto/svote/v1/tx.proto](https://github.com/valargroup/vote-sdk/blob/main/proto/svote/v1/tx.proto).
* Draft §Vote Commitment Transaction.

***

## 9. After the vote confirms: record positions

The cast-vote transaction appends leaves to the vote commitment tree (the new VAN and one vote commitment per proposal). The confirmation calls in §8 persist those positions; the vote-commitment position (`vc_tree_position`) is a required input to helper-share payloads, and the new VAN position feeds any later ballot from the same bundle. Re-run `sync_vote_tree` after confirmation so the local tree contains the new leaves.

***

## 10. Helper-share submission and anti-censorship

### Why shares

Each vote is split into 16 encrypted shares (`VOTE_COMMITMENT_SHARE_COUNT`). Each share is ElGamal-encrypted under `ea_pk` and delivered to helper servers. A helper generates ZKP3 and submits `MsgRevealShare` to the chain; the chain homomorphically accumulates shares across all voters, and the Election Authority decrypts only the aggregate tally at finalization.

Delaying each share to a random later time stops a network observer from correlating a delegation TX with the resulting vote. Near `vote_end_time` the planner collapses to a single immediately-submitted share so late voters are not disenfranchised (controlled by `last_moment_buffer_seconds` and reflected in the committed vote's payload count) — some timing-correlation resistance is traded for liveness.

### The SDK owns delivery

Since 3.x the wallet no longer builds share bodies, schedules POSTs, or tracks confirmations by hand. Three crate calls own the flow, all journaled in the sidecar:

**1. Plan — once per committed vote, then persist:**

```rust
// From wallet-example/src/example_vote.rs (plan_committed_vote_shares)
let urls = canonical_helper_url_list(configured_server_urls)?;   // must be distinct
let share_count = committed.share_payloads().len();              // 16, or 1 near the deadline
let required = share_submission_random_bytes_required(
    share_count, urls.len(), now_seconds, vote_end_time_seconds,
    last_moment_buffer_seconds, share_count == 1,
);
let share_plans = plan_share_submissions(
    share_count, &urls, now_seconds, vote_end_time_seconds,
    last_moment_buffer_seconds, share_count == 1, immediate_share_index,
    &os_random_bytes(required.submit_at_random_bytes),
    &os_random_bytes(required.server_random_bytes),
)?;
// Persist (urls, share_plans) durably BEFORE submitting the first share.
```

**2. Submit — journaled delivery per share:**

```rust
let client = HelperClient::new(transport /* caller-owned Arc<dyn HelperTransport> */, helper_health);
for (share_index, plan) in share_plans.iter().enumerate() {
    committed.submit_share_to_helpers(
        &voting_db,
        &client,
        ShareSubmissionRequest { share_index: share_index as u32, plan,
                                 configured_server_urls: &urls, now_seconds },
        &cancel,
    ).await?;
}
```

**3. Track — periodic confirmation and recovery pass:**

```rust
track_pending_shares(&voting_db, &ShareTrackingParams { round_id, configured_server_urls,
    now_seconds, vote_end_time_seconds, policy: ShareTimingPolicy::default(),
    random_bytes: &os_random_bytes }, &client, &cancel).await?;
// Schedule the next pass from the report's next_delay_seconds.
```

### Wallet-facing invariants

The normative specification is [helper\_submission\_invariants.md](https://github.com/valargroup/zcash_voting/blob/main/docs/helper_submission_invariants.md) in the SDK repo; the wallet-side obligations reduce to this checklist:

* **Persist the complete plan set before the first POST, and reuse it after restart.** Never replan only the missing shares — that loses the commitment-wide helper balancing and per-helper quota context.
* **Helper URLs must be canonically distinct** (`canonical_helper_url_list` rejects duplicates that differ only in case, port, or trailing slash). The current fleet may differ from the planning fleet, but every stored plan must remain valid against it.
* **Don't double-record.** `submit_share_to_helpers` journals every attempt durably before dispatch; the wallet must not persist its results a second time.
* **Confirmation needs two helpers.** `track_pending_shares` requires two distinct configured helpers to agree before persisting a share as confirmed.
* **Transport, timers, and cancellation are wallet-owned.** `HelperClient` takes a caller-supplied transport (`HelperTransport`) so delivery can be routed through Tor or a proxy; the wallet owns the tracking timer, app-lock and round-expiry cancellation.
* Helper health (failure threshold, cooldown) is applied by the crate: `HELPER_FAILURE_THRESHOLD`, `HELPER_COOLDOWN_SECONDS`.

The helper endpoints (`POST /shielded-vote/v1/shares`, `GET /shielded-vote/v1/share-status/{roundId}/{nullifier}`) are called by `HelperClient` internally; wallets do not construct those requests.

***

## 11. Crash recovery and resumption

Everything above persists in the voting sidecar, so recovery is a planning call, not a re-execution:

```rust
// From wallet-example/src/example_recovery.rs
let plan: RoundPlan = resume_plan(&voting_db, round_id, &proposal_ids)?;
for step in &plan.next_steps {
    match step {
        NextStep::SubmitVote { bundle_index, proposal_id } |
        NextStep::SubmitShares { bundle_index, proposal_id, .. } => {
            // Singleton: reload without rebuilding the proof.
            let committed = CommittedVote::recover(&voting_db, round_id, *bundle_index, *proposal_id)?;
            // resubmit via committed.submission / persisted share plan
        }
        NextStep::SubmitVoteBatch { bundle_index, proposal_id } |
        NextStep::PollVoteBatch { bundle_index, proposal_id } => {
            let batch = recover_atomic_vote_batch(&voting_db, round_id, *bundle_index, *proposal_id)?;
            // SubmitVoteBatch: POST batch.batch_json once; keep batch.batch_digest
            // for record_vote_batch_submission / confirm_vote_batch_submission.
        }
        _ => { /* Delegate / poll-delegation / CastVote steps, in planner order */ }
    }
}
```

Rules:

* Execute steps in planner order, and **re-run `resume_plan` after each durable action** — later work may depend on on-chain confirmations recorded by the previous step.
* `NextStep::CastVote` steps include the recorded `Decision::Choice` from §5; `plan.open_proposals` lists proposals with no terminal decision yet.
* For `SubmitShares`, reuse the wallet's persisted complete helper plan (§10) — never replan.
* `round_snapshot` returns a typed `RoundRecoverySnapshot` of everything recorded for a round, useful for support/debug UI.

***

## 12. Delegation capability handoff (custody split)

When the party controlling the funds (a custody provider, an exchange, a treasury multisig operator) is not the party who will vote, the SDK supports a role-separated flow where the **voter** keeps the hotkey secret and the **funds controller** prepares and signs delegations against a public target. Documented in full in [Exporting to external software](https://github.com/valargroup/zcash_voting/blob/main/docs/exporting-to-external-software.md); the sequence (from `wallet-example/src/example_capability_handoff.rs`):

1. **Voter** creates a round-bound public target from their hotkey (`VotingHotkeyTargetV1` JSON — no secret material crosses the boundary) and sends it to the controller.
2. **Controller** validates the target against its own chain/network/round context, then runs the normal delegation flow (§7) using `prepare_delegation_bundle_for_target`.
3. **Controller** exports a capability package binding the signed delegation transaction bytes: `export_delegation_capability` → durably store `canonical_json()` + `digest()`, deliver the JSON, broadcast the delegations (possibly concurrently).
4. **Voter** imports with `import_delegation_capability` and returns the digest as a delivery receipt; the controller redelivers the same bytes on mismatch or missing acknowledgement, through round close.
5. **Voter** records each bundle's confirmed delegation via `confirm_delegation_submission`; imported rounds cannot vote until every bundle has a public VAN position.
6. **Voter** votes normally (§8–§10).

***

## 13. Results

```
GET /shielded-vote/v1/tally-results/{round_id}
```

Available after the chain transitions the round to `FINALIZED` (status 3). Returns one entry per `(proposal_id, vote_decision)` pair:

```json
{
  "results": [
    {
      "vote_round_id": "<base64, 32 bytes>",
      "proposal_id":   1,
      "vote_decision": 0,
      "total_value":   1234567
    }
  ]
}
```

`total_value` is in **zatoshi** (10⁻⁸ ZEC). The chain has already aggregated; a wallet renders totals per option with no client-side math. Absence of an entry for a `(proposal, decision)` pair means zero votes.

***

## 14. Wire bodies and encoding

**The SDK owns wire encoding.** Every chain-facing request body is produced by the crate — `DelegationSubmission::to_wire_json()`, `SignedVoteCommitment::to_wire_json()`, `SignedVoteBatch.batch_json`, and helper-share bodies inside `HelperClient`. Wallets submit these strings verbatim and must not hand-assemble or re-serialize them; field-level encodings (hex vs base64 per field) are canonicalized by the crate and validated by the chain.

The corresponding wire structs, for reference when inspecting traffic, are in `zcash_voting/src/wire.rs`: `DelegationSubmissionWire`, `VoteCommitmentWire`, `VoteCommitmentBatchWire` (`{"votes": [...]}` — ordered, order is digest-bound), `VoteShareWire`.

Encodings the wallet does touch directly:

| Context                                         | Encoding                 |
| ----------------------------------------------- | ------------------------ |
| Round ids in config and SDK APIs (`round_id`)   | hex (64 lowercase chars) |
| URL path parameters (`{round_id}`, `{roundId}`) | hex (64 lowercase chars) |
| Chain query response bodies                     | base64 (32 bytes)        |

Cryptographic types, for completeness: Pallas base field elements are 32 bytes little-endian canonical (reject ≥ modulus); compressed Pallas points 32 bytes; ElGamal ciphertexts 64 bytes (`C1‖C2`); RedPallas signatures 64 bytes (`R‖s`); Halo 2 proofs are opaque variable-length bytes.

***

## 15. Version handling

### Crate versioning

`zcash_voting` releases to crates.io as a 3.x series (currently `3.1.0-rc.13`). Pin an exact version (`=3.1.0-rc.13`) while the series is in release candidates, and read the repo's `CHANGELOG.md` before bumping — the crate has been evolving quickly and migration notes live in the README ("Migrating 0.11 to 0.12" and successors). Note the repo's git tags historically used the `zcash_voting-v*` form and are moving to plain `v3.x`; crates.io is the authoritative release channel for integrators. Pre-launch sidecar databases with older schema versions are **reset** when opened by a newer crate.

### Server protocol versions

All server version strings use `"v" MAJOR` form (`"v0"`, `"v1"`). REST endpoint paths include the `vote_server` major version: `"v1"` → `/shielded-vote/v1/`. The resolved config's `supported_versions` carries the server-side versions (`vote_server`, `vote_protocol`, `tally`, `pir`); the SDK's config resolution checks compatibility during §3.

***

## 16. Security and privacy considerations

* **Config authenticity.** Pin the static config source (URL + hash pin where available) in-app; `ea_pk` must always come from `trusted_voting_round_params`, never from an unauthenticated server response.
* **Seed boundary.** All signing happens in wallet-owned code against a `DelegationSigningRequest`; verify `request.seed_fingerprint` against the seed you are about to use before signing (see `example_sign_delegation_request`).
* **Hardware-wallet flow.** Every delegation must route through the device; a multi-bundle delegation means one QR round-trip per bundle. Don't cache the user's seed in-process to shortcut the rest.
* **Privacy trim disclosure.** `PrivacyTrim` reports note value excluded from voting; surface it to the user rather than silently dropping weight.
* **Helper selection privacy.** Share placement, timing randomization, and per-helper quotas are crate-enforced; routing `HelperClient` and the PIR transport through the wallet's anonymizing transport (where available) strengthens the network-observer story further.
* **Double-submission.** Nullifiers (`gov_nullifiers`, `van_nullifier`) are recorded permanently per round; drive retries from `resume_plan`, not from a UI-level "try again" that re-sends blindly.
* **Observability.** A wallet that logs proof content, ciphertexts, sighashes, or PIR query bytes to telemetry leaks user state. Log only opaque transaction hashes and counts.

***

## 17. Platform appendix: iOS (zodl-ios reference)

[zodl-ios](https://github.com/valargroup/zodl-ios/tree/shielded-vote) on the `shielded-vote` branch is the reference integration; the Rust crate is bound over a C FFI in [`zcash-swift-wallet-sdk`](https://github.com/valargroup/zcash-swift-wallet-sdk/tree/shielded-vote) — see `VotingRustBackend.swift` there for the Swift facade over the FFI.

Keystone hardware signing follows the shape of §7.3: build the governance PCZT (`keystone_request` on the Rust side), redact for the signer, QR round-trip, then extract the SpendAuth signature and pair it with the original setup sighash.

***

## 18. Full end-to-end sequence

```mermaid
sequenceDiagram
    autonumber
    participant W as Wallet<br/>(zcash_voting)
    participant C as Config publisher
    participant V as Vote Server<br/>(vote-sdk)
    participant P as PIR Server
    participant H as Helper API

    Note over W,C: Discovery (§3–§4)
    W->>C: GET static config
    C-->>W: static anchor
    W->>C: GET dynamic config (mirror walk)
    C-->>W: dynamic config (authenticated)
    W->>V: GET /rounds/active
    V-->>W: VoteRound (status=ACTIVE)
    W->>W: trusted_voting_round_params (ea_pk from config)

    Note over W,P: Setup + precompute (§5–§6)
    W->>W: ensure_round / ensure_bundles / hotkey
    W->>P: precompute_pir_proofs (background warmup)
    P-->>W: cached nullifier proofs

    Note over W,V: Delegation, per bundle (§7)
    W->>W: prepare → precompute → setup
    W->>W: wallet signs (software or Keystone QR)
    W->>P: prove (reads warmed cache)
    W->>V: POST /delegate-vote (to_wire_json)
    V-->>W: {tx_hash, code=0}
    loop until confirmed
      W->>V: GET /tx/{hash}
      V-->>W: {height, code, events}
    end
    W->>W: confirm_delegation_submission(events)
    W->>V: sync_vote_tree
    V-->>W: leaves → van_witness

    Note over W,V: Voting (§8) — one atomic batch
    W->>W: commit_atomic_vote_batch(drafts)
    W->>V: POST /cast-vote-batch (batch_json, once)
    V-->>W: {tx_hash, code=0}
    W->>V: GET /tx/{hash} → events
    W->>W: confirm_vote_batch_submission(events)

    Note over W,H: Shares (§10)
    W->>W: plan_share_submissions → persist plan
    par per share, at planned submit_at
      W->>H: submit_share_to_helpers (journaled)
      H-->>W: accepted
    end
    loop tracking passes
      W->>H: track_pending_shares
      H-->>W: statuses (2-helper agreement)
    end

    Note over W,V: Results (after FINALIZED)
    W->>V: GET /tally-results/{round_id}
    V-->>W: results
```

***

## Appendix: endpoint reference

The SDK performs tree-sync, PIR, and helper calls itself; the wallet's own HTTP code touches only the config fetch, the round/tx queries, and the two submission POSTs. Listed here for completeness and traffic inspection.

### Vote server endpoints

Rooted at a `vote_servers[*].url` from the resolved config (chain + helper API share a host).

| Method + Path                                                                       | Purpose                      | Called by            |
| ----------------------------------------------------------------------------------- | ---------------------------- | -------------------- |
| `GET /shielded-vote/v1/rounds/active`                                               | Active round                 | wallet               |
| `GET /shielded-vote/v1/rounds`                                                      | All rounds                   | wallet               |
| `GET /shielded-vote/v1/round/{round_id}`                                            | Specific round               | wallet               |
| `GET /shielded-vote/v1/tx/{hash}`                                                   | Tx status + events           | wallet               |
| `POST /shielded-vote/v1/delegate-vote`                                              | Submit ZKP1 (per bundle)     | wallet               |
| `POST /shielded-vote/v1/cast-vote`                                                  | Submit one singleton ZKP2    | wallet               |
| `POST /shielded-vote/v1/cast-vote-batch`                                            | Submit one atomic vote batch | wallet               |
| `GET /shielded-vote/v1/tally-results/{round_id}`                                    | Finalized tallies            | wallet               |
| `GET /shielded-vote/v1/commitment-tree/{round_id}/latest`                           | Latest tree root             | SDK (tree sync)      |
| `GET /shielded-vote/v1/commitment-tree/{round_id}/{height}`                         | Tree root at height          | SDK (tree sync)      |
| `GET /shielded-vote/v1/commitment-tree/{round_id}/leaves?from_height=X&to_height=Y` | Tree leaves (sync)           | SDK (tree sync)      |
| `POST /shielded-vote/v1/shares`                                                     | Submit one share to helper   | SDK (`HelperClient`) |
| `GET /shielded-vote/v1/share-status/{roundId}/{nullifier}`                          | Share confirmation           | SDK (`HelperClient`) |
| `GET /shielded-vote/v1/readiness`                                                   | Server readiness             | SDK                  |

Non-normative / operator-only endpoints wallets generally do not need: `rounds/overview`, `vote-summary/{round_id}`, `tally/{round_id}/{proposal_id}`, `partial-decryptions/{round_id}`, `ceremony`, `pallas-keys`, `vote-managers`, `coordinator-actions`, `endorsers`, `endorsed-rounds/{id}`, `genesis`, `snapshot-data/{height}`, `reveal-share`, `status`, `queue-status`, `queue-summary/{roundId}`.

### PIR server endpoints

Rooted at a `pir_endpoints[*].url` from the resolved config. Reached exclusively through the SDK (`connect_pir` / `connect_pir_blocking`); wallets do not call them directly.

| Method + Path                        | Purpose                    |
| ------------------------------------ | -------------------------- |
| `GET /tier0`                         | Tier 0 bootstrap           |
| `GET /params/tier1`, `/params/tier2` | YPIR params                |
| `POST /tier1/query`, `/tier2/query`  | Encrypted nullifier lookup |
| `GET /root`                          | Current tree root + height |
| `GET /health`                        | Server state               |
