Skip to content

Expose PCZT getters and add governance wallet query methods#2

Closed
greg0x wants to merge 2 commits into
maint/zcash_client_sqlite-0.19.xfrom
valargroup/pczt-governance-extensions-0.11
Closed

Expose PCZT getters and add governance wallet query methods#2
greg0x wants to merge 2 commits into
maint/zcash_client_sqlite-0.19.xfrom
valargroup/pczt-governance-extensions-0.11

Conversation

@greg0x
Copy link
Copy Markdown

@greg0x greg0x commented Mar 6, 2026

Adds the minimal librustzcash surface needed for Zcash shielded voting (governance protocol). Targets maint/zcash_client_sqlite-0.19.x (orchard 0.11); same changes apply cleanly to main (orchard 0.12).

PCZT changes (pczt crate)

  • Spend::spend_auth_sig getter — read back the hardware wallet signature after signing, so it can be threaded into a ZK delegation proof
  • Signer::shielded_sighash() getter — expose the cached sighash (already on upstream main, backported here)

Wallet changes (zcash_client_sqlite crate)

Two new governance-specific methods on WalletDb (inherent, not on wallet traits — these don't belong in the general-purpose API):

  • get_orchard_notes_at_snapshot(account, height) — returns all Orchard notes received at or before snapshot_height and unspent as of that height. Backward-looking query for voting snapshots, unlike select_unspent_notes which is forward-looking (based on tx expiry).

  • generate_orchard_witnesses_at_frontier(positions, frontier, height) — copies wallet shard data to an ephemeral in-memory database, inserts the lightwalletd frontier as a checkpoint, and generates Merkle witnesses at the snapshot anchor. The wallet DB is strictly read-only.

Internal visibility changes

  • SqliteShardStore::from_connection widened to pub (needed by generate_orchard_witnesses_at_frontier callers who construct ephemeral stores)
  • orchard::Bundle::serialize_from narrowed to pub(crate) — only used internally by the creator role

Context

Governance protocols need to construct Orchard-only PCZTs for hardware wallet signing, query wallet notes at a historical snapshot, and generate witnesses anchored at that snapshot's frontier (even if the wallet has synced past it). These changes cleanly separate concerns: librustzcash owns the wallet domain, the voting library owns the voting domain, and the SDK wires them together.

@greg0x greg0x force-pushed the valargroup/pczt-governance-extensions-0.11 branch from 67af478 to c3abb34 Compare March 6, 2026 17:01
@greg0x greg0x changed the title Expose PCZT and wallet internals for governance signing Expose PCZT internals and add governance wallet query methods Mar 10, 2026
greg0x pushed a commit that referenced this pull request Mar 12, 2026
greg0x added a commit that referenced this pull request Mar 12, 2026
The AsyncThrowingStream closures used bare `try` inside unstructured
Tasks with no do/catch. When Rust rejected the empty placeholder
witness, the error was silently swallowed and the continuation was
never finished — freezing the UI at step 7/8.

Wrap both proof stream bodies (ZKP #1 and #2) in do/catch so errors
propagate via continuation.finish(throwing:). Also provide non-empty
placeholder witness data so the stub accepts it.
greg0x added a commit that referenced this pull request Mar 12, 2026
The protocol (§3.3.1) requires exactly 4 encrypted shares per vote —
shares_hash = H(enc_share_1..4) and ZKP #2 verifies 4 preimage checks.

decompose_weight now produces exactly 4 shares: binary decomposition
followed by round-robin bucketing when more than 4 bits are set.
Previously it returned one share per set bit (up to 64), which hit
the ElGamal guard for any balance with >4 bits set — like the
simulator's 101768753 (12 bits).

Also adds error logging to the vote submission effect so failures
are visible in the console instead of silently swallowed.
greg0x pushed a commit that referenced this pull request Mar 12, 2026
greg0x pushed a commit that referenced this pull request Mar 12, 2026
* rename gov_comm to van_cmx, add duplicate nullifier check in DelegateVote

- Rename proto field gov_comm (field 6) to van_cmx in MsgDelegateVote
- Regenerate protobuf and update all Go references
- Add duplicate nullifier check before SetNullifier in DelegateVote handler

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix e2e test: rename gov_comm to van_cmx in delegation payload

Match the proto field rename (gov_comm → van_cmx) in the JSON payload
sent by the Rust e2e tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* remove cmx_new from vote commitment tree

Only van_cmx is appended to the commitment tree during delegation.
cmx_new is recorded on-chain but not included in the tree — no
subsequent proof references it; only the VAN needs a Merkle path
for ZKP #2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix e2e test: tree next_index >= 1 after delegation (only van_cmx)

cmx_new is no longer appended to the tree, so next_index after a
single delegation is 1, not 2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix e2e test: remove extra cmx_new arg from build_van_merkle_witness call

The merge brought in a call with 3 args but the function now takes 2
(only van_cmx + checkpoint_height since cmx_new was removed from tree).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix e2e test: expect 1 leaf after delegation (only van_cmx)

Since cmx_new was removed from the commitment tree, delegation now
appends only van_cmx (1 leaf) instead of 2. Update the tree sync
assertion accordingly.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix merge: rename GovComm to VanCmx in sighash.go

The sighash.go file was added on main and references msg.GovComm, but
this branch renamed the proto field to van_cmx (VanCmx in Go).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix e2e test: update all leaf counts for single-leaf delegation

Delegation now appends only van_cmx (1 leaf) instead of 2, so:
- delegation wait: next_index >= 1 (was >= 2)
- cast-vote wait: next_index >= 3 (was >= 4)
- post-cast assertion: next_index == 3 (was == 4)
- tree sync assertion: contains "3" (was "4")

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
greg0x pushed a commit that referenced this pull request Mar 12, 2026
…cash#69)

* Ballot scaling in ZKP #1: convert zatoshi to ballot count

Replace the minimum-weight check (condition 8) with ballot scaling
that floor-divides v_total by 12,500,000 to produce num_ballots.
Condition 7 now hashes num_ballots into the VAN commitment instead
of the raw v_total.

Circuit constraints for condition 8:
- num_ballots * BALLOT_DIVISOR + remainder == v_total
- remainder < 2^24 (via shift-by-2^6 into 30-bit lookup check)
- 0 < num_ballots <= 2^30 (via nb_minus_one 30-bit range check)

Adds MulChip (c = a * b gate) used for the reconstruction constraint
and the remainder bit-shift.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Apply ballot scaling to VAN hashes in ZKP #2 and librustvoting

ZKP #1 now hashes num_ballots (not raw zatoshi) into VAN commitments.
Update all downstream VAN hash callers to match:
- vote_proof/builder.rs: convert total_note_value to num_ballots before
  VAN integrity hashing and share splitting
- governance.rs: construct_van now divides total_weight by BALLOT_DIVISOR
- Update test values to use weights >= 12,500,000 (one ballot minimum)
- Freeze new known-answer VAN test vector

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
greg0x pushed a commit that referenced this pull request Mar 12, 2026
Wire real ZKP #3 (share reveal) proofs into the E2E voting flow
and fix the Go/Rust El Gamal generator mismatch so auto-tally
BSGS decryption succeeds end-to-end.

- Add orchard/src/share_reveal/ circuit, builder, and prover (K=14)
- Expose share_reveal via sdk/circuits FFI for on-chain verification
- Update Go PallasGenerator() to use SpendAuthG (matching ZKP #2 circuit)
- Add Go ZKP #3 proof verification in sdk/crypto/zkp
- E2E test submits 2 of 4 real share reveals, waits for auto-tally,
  and asserts exact decrypted total_value = 7,500,000
- Wire helper-server share-reveal processing and nullifier tracking
- Regenerate FFI bindings (xcframework + Swift)
- Remove dead mock reveal_share_payload and unused encrypt_share
greg0x added a commit that referenced this pull request Mar 12, 2026
The stub returned hardcoded shares_hash (0xDD), vote_decision (0), and
tree_position (0). Now threads real values: shares_hash from the vote
commitment (Poseidon hash of encrypted share x-coordinates, already
computed by the ZKP #2 builder), vote_decision from the caller, and
vc_tree_position (known after cast-vote TX confirms on chain).

shares_hash added to VoteProofBundle and VoteCommitmentBundle so it
flows from builder → librustvoting → FFI → Swift without recomputation.
greg0x added a commit that referenced this pull request Mar 12, 2026
The existing voting_flow test calls the orchard builder directly.
This new test exercises the production library stack: VotingDb
persistence, TreeClient HTTP sync from chain, witness generation,
and ZKP #2 proof generation — all through the librustvoting and
vote-commitment-tree-client APIs.

Changes:
- Make derive_spending_key public in librustvoting so tests can
  derive a SpendingKey from the same hotkey seed used in production
- Parametrize build_delegation_bundle_for_test to accept an optional
  SpendingKey (for seed-derived key consistency between ZKP #1 and #2)
- Add librustvoting + vote-commitment-tree-client deps to e2e-tests
  with the necessary [patch.crates-io] entries
greg0x added a commit that referenced this pull request Mar 12, 2026
Steps 10-12 of the ZKP 2 wiring plan: update the Swift layer to
match the new FFI signatures and replace the mock VAN witness with
the real tree sync + witness generation pipeline.

VotingModels:
- VoteCommitmentBundle now carries encShares, anchorHeight, and
  sharesHash (returned by ZKP #2 builder). voteRoundId changed
  from Data to String (hex). Removed voteCommTreeAnchorHeight.
- SharePayload: removed redundant shareIndex (lives in encShare)
- Added VanWitness model (authPath, position, anchorHeight)

VotingCryptoClient interface:
- buildVoteCommitment takes hotkeySeed, networkId, vanAuthPath,
  vanPosition, anchorHeight instead of encShares + vanWitness Data
- buildSharePayloads takes voteDecision + vcTreePosition
- New methods: storeVanPosition, syncVoteTree, generateVanWitness

VotingCryptoClientLiveKey:
- buildVoteCommitment calls FFI with new signature, maps encShares
  from the result bundle
- buildSharePayloads passes voteDecision + vcTreePosition to FFI,
  constructs full FFI VoteCommitmentBundle with all 9 fields
- constructDelegationAction/buildGovernancePczt pass addressIndex: 1
- New storeVanPosition/syncVoteTree/generateVanWitness wrappers

VotingStore (.confirmVote):
- Replaced decompose → encrypt → mock-witness → build flow with
  syncVoteTree → generateVanWitness → buildVoteCommitment flow
- Derives hotkeySeed from wallet storage for ZKP #2
- Passes bundle directly to submitVoteCommitment (no re-wrapping)
- Builds share payloads from bundle.encShares
- TODOs for chainNodeBaseUrl config and vcTreePosition from TX events
greg0x added a commit that referenced this pull request Mar 12, 2026
The cast-vote TX was missing r_vpk coordinates, sighash, and vote_auth_sig
— the chain rejected submissions without these fields.

New signCastVote crypto client method calls FFI to compute the canonical
sighash (Blake2b-256 matching Go's ComputeCastVoteSighash), decompress
r_vpk to (x, y) affine coordinates, and sign with the randomized voting
key rsk_v = ask_v.randomize(alpha_v).

VoteCommitmentBundle model now carries rVpkBytes and alphaV from ZKP #2.
submitVoteCommitment API call includes all required fields: r_vpk_x,
r_vpk_y, r_vpk, sighash, vote_auth_sig.
greg0x pushed a commit that referenced this pull request Mar 12, 2026
…imeout

The test hardcoded VAN at position 0 and VC at position 2, but the
commitment tree is global across rounds — if prior leaves exist, the
Merkle witness targets the wrong position and the ZKP #2 proof fails
on-chain verification.

Capture pre_delegate_next_index before delegation and compute all
positions relative to it. Also compute the Step 12 TALLYING timeout
dynamically from vote_end_time instead of a fixed 250s.
greg0x added a commit that referenced this pull request Mar 12, 2026
The helper server's background tree sync loop would sync past share
positions before they were marked for witness retention, causing
"no witness for position X" errors for every share. This happened
because shares arrive ~40s after the tree has already been synced
(the test spends that time generating ZKP #1 and #2).

TreeSync now tracks marked positions separately and provides
witness_or_resync() which rebuilds the tree from scratch with all
marked positions if the fast path fails. The API handler also marks
positions eagerly on share arrival.

Moved the helper server default port from 9090 to 9091 to avoid
conflicting with the chain's gRPC server. Updated e2e test defaults
to use 127.0.0.1 (avoids macOS IPv6 resolution issues) and port
1318 (matches init.sh).
greg0x added a commit that referenced this pull request Mar 12, 2026
Rewrote voting_flow_librustvoting.rs to exercise the complete path:
delegation (ZKP #1) → cast-vote (ZKP #2) → helper server (ZKP #3)
→ tally accumulation → auto-tally finalization → result verification.

Previously the test generated ZKP #3 inline and submitted reveal-share
directly to the chain. Now it sends share payloads to the helper
server, which handles tree sync, witness generation, ZKP #3 proof,
and chain submission — matching the production Zashi flow.

Key fixes: use the chain's EA public key (from ~/.zallyd/ea.pk)
instead of generating a random keypair, which was preventing
auto-tally decryption. Reduced the default vote window from 480s
to 180s since the helper server processes shares much faster than
inline ZKP #3 generation.
greg0x added a commit that referenced this pull request Mar 12, 2026
The voting_round_id is a Blake2b-256 hash, and ~75% of 32-byte hash
values exceed the Pallas field modulus. zkp2.rs was using strict
from_repr() which rejects non-canonical values, while zkp1.rs,
governance.rs, and the chain verifier all correctly use wide reduction
via from_uniform_bytes(). This caused vote commitment building to fail
with "voting_round_id is not a valid Pallas field element" depending
on the round's hash.

The e2e test had the same latent bug (working by luck when the hash
happened to be canonical).
greg0x added a commit that referenced this pull request Mar 12, 2026
confirmVote previously fired the full on-chain pipeline (tree sync, ZKP #2,
cast-vote TX, share delegation) as a background effect while immediately
advancing to the next proposal after 600ms. This meant errors were silently
swallowed and the user had no indication of whether their vote actually landed.

Now the UI stays on the proposal detail screen with isSubmittingVote=true
until the entire pipeline completes. On success, advanceAfterVote fires.
On failure, voteSubmissionFailed removes the optimistic vote and surfaces
the error.
greg0x pushed a commit that referenced this pull request Mar 12, 2026
- Add sentinel injection (k*2^250 for k=0..16) to pir-export to satisfy
  circuit gap-width constraint (#3)
- Change Tier 2 empty-leaf padding from Fp::zero() to -Fp::one() so
  trailing entries sort after real leaves, fixing binary search (#2)
- Make TierServer::answer_query() return Result with input validation
  (length checks, alignment) instead of panicking on malformed
  requests; handlers return HTTP 400 on error (#1)
- Replace unwrap/assert with fallible returns in pir-client and
  Tier0Data::from_bytes (#4)
greg0x pushed a commit that referenced this pull request Mar 12, 2026
Adds a defense-in-depth constraint to ZKP #2 Condition 6 that prevents
proposal_id = 0 (the dummy sentinel value) from passing verification.

The range check alone does not exclude zero; this gate closes that gap
using the standard inverse-witness technique:

  q_cond5 * (1 - proposal_id * proposal_id_inv) = 0

This is satisfiable iff proposal_id ≠ 0, since a field inverse exists
only for non-zero elements.

Implementation:
- Config: add proposal_id_inv column (advices[2] on row 0 of the cond6
  region, which is otherwise unused on that row)
- configure(): add "proposal_id != 0" gate gated by q_cond5
- synthesize(): witness proposal_id⁻¹ on row 0 of the cond6 region
- Tests: update three tests that used proposal_id = 0 as a placeholder
  to use valid non-zero ids; add proposal_id_zero_fails test to
  explicitly verify the rejection

Co-authored-by: Cursor <cursoragent@cursor.com>
greg0x pushed a commit that referenced this pull request Mar 12, 2026
Replace deterministic shares_hash = Poseidon(c1_0_x, c2_0_x, ..., c1_4_x, c2_4_x)
with per-share blinded commitments:
  share_comm_i = Poseidon(blind_i, c1_i_x, c2_i_x)
  shares_hash  = Poseidon(share_comm_0, ..., share_comm_4)

This prevents observers from recomputing shares_hash from on-chain encrypted
shares and linking them back to a specific vote commitment, fixing a voter
privacy leak.

Changes span the full stack:
- Circuits (orchard): ZKP #2 and ZKP #3 condition 3/10 use ConstantLength<3>
  per-share hashes + ConstantLength<5> final hash instead of ConstantLength<10>
- Rust lib (librustvoting): share_blinds plumbed through types and builders
- C FFI (sdk/circuits): share_blinds_ptr/len params on share reveal function
- Go helper (sdk): wire types, API validation, store schema, processor, CGo
- UniFFI bridge + iOS: shareBlinds/shareBlindFactors through FFI and Swift
greg0x pushed a commit that referenced this pull request Mar 12, 2026
…#3

Move the two-level Poseidon shares-hash computation (per-share blinded
commitments + outer hash) from vote_proof/circuit.rs into a new
shared_primitives::shares_hash module, and replace the inlined version
in share_reveal/circuit.rs with calls to the same gadget.

Co-authored-by: Cursor <cursoragent@cursor.com>
greg0x pushed a commit that referenced this pull request Mar 12, 2026
…gadget

refactor(orchard): extract shares-hash gadget shared by ZKP #2 and ZKP #3
greg0x pushed a commit that referenced this pull request Mar 12, 2026
greg0x pushed a commit that referenced this pull request Mar 12, 2026
Bind the vote commitment to its voting round by including
voting_round_id in the Poseidon hash:

  vote_commitment = Poseidon(DOMAIN_VC, voting_round_id,
                             shares_hash, proposal_id, vote_decision)

This prevents cross-round replay where an attacker reuses a ZKP #3
proof from round A against round B, injecting shares encrypted under
the wrong election key. The change updates both ZKP #2 (condition 12)
and ZKP #3 (condition 2) circuits, out-of-circuit helpers, builders,
FFI, tests, and documentation.
greg0x pushed a commit that referenced this pull request Mar 12, 2026
Moves DOMAIN_VC, vote_commitment_hash (out-of-circuit), and a new
vote_commitment_poseidon gadget into circuit/vote_commitment.rs,
mirroring the van_integrity pattern. ZKP #2 (cond 12) and ZKP #3
(cond 2) now both call the shared gadget instead of duplicating
the 5-input Poseidon block inline. Resolves the TODO in vote_proof/circuit.rs.

Made-with: Cursor
greg0x pushed a commit that referenced this pull request Mar 12, 2026
The vote proof circuit was regenerating IPA params and proving/verifying
keys on every call (~30s on device). Add a process-level OnceLock cache
matching the pattern ZKP #1 already uses for the delegation circuit.

Made-with: Cursor
greg0x pushed a commit that referenced this pull request Mar 12, 2026
Cache vote proof (ZKP #2) keygen with OnceLock
greg0x pushed a commit that referenced this pull request Mar 12, 2026
…mark

Voter throughput stress test with real ZKP #1/#2/#3
@greg0x greg0x changed the title Expose PCZT internals and add governance wallet query methods Expose PCZT getters and add governance wallet query methods Mar 12, 2026
greg0x added 2 commits March 12, 2026 17:47
External governance protocols (shielded voting) need to:

- Replace the Orchard bundle in a PCZT after constructing a custom
  governance action (Pczt::set_orchard)
- Read back the spend_auth_sig after a hardware wallet signs the PCZT,
  so the signature can be threaded into a ZK delegation proof
- Serialize an orchard::pczt::Bundle into the PCZT wire format from
  outside the crate (Bundle::serialize_from)
- Construct an ephemeral SqliteShardStore from a raw connection to build
  Merkle witnesses without going through WalletDb
  (SqliteShardStore::from_connection)

Remove set_orchard and narrow serialize_from visibility (#3)

Clean up PCZT APIs that are no longer needed now that governance PCZT construction uses `Creator::build_from_parts` (see valargroup/zcash_voting#1).

- Remove `Pczt::set_orchard()` — was only used by librustvoting to manually inject an orchard bundle after creating an empty PCZT shell. No longer needed since `build_from_parts` accepts the bundle directly.
- Narrow `orchard::Bundle::serialize_from` from `pub` to `pub(crate)` — only used internally by the creator role, no external callers.
WalletDb gains two new methods (not on traits — governance-specific):

- get_orchard_notes_at_snapshot(account, height): returns all Orchard
  notes received at or before snapshot_height and unspent as of that
  height. Backward-looking query for voting snapshots, unlike
  select_unspent_notes which is forward-looking.

- generate_orchard_witnesses_at_frontier(positions, frontier, height):
  copies wallet shard data to ephemeral in-memory DB, inserts the
  lightwalletd frontier as a checkpoint, and generates Merkle witnesses.
  Wallet DB is strictly read-only.

These replace the wallet DB access previously embedded in librustvoting,
completing the clean separation: librustzcash owns wallet domain,
librustvoting owns voting domain, SDK wires them together.

Fix rustfmt and clippy warnings in generate_orchard_witnesses_at_frontier

Applies rustfmt formatting and replaces redundant closures with direct
function references (`.map_err(SqliteClientError::DbError)`).
@greg0x greg0x force-pushed the valargroup/pczt-governance-extensions-0.11 branch from c2bc669 to 0669f6c Compare March 12, 2026 16:48
@greg0x
Copy link
Copy Markdown
Author

greg0x commented Mar 12, 2026

Closing to reopen as a clean PR — the timeline on this one picked up noise from monorepo cross-references.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant