I just went through the pain of the current ZCash tokenholder vote process. This is crazy, had to send funds out of my private key, and paste the mnemonic into an app. We all know we can do far better.
I propose the following design that involves no on-chain logic changes. If this matches other people’s impressions + security gut-check, we’ll build it. An important goal is to make this quickly available for the next March vote (no hardware wallet or protocol upgrade required)
Governance requirements
- Does not require on-chain moving your funds as a user
- There is a snapshot height
- every shielded orchard ZEC held at a given snapshot height is eligible for voting.
- (obvious) any given ZEC can only be counted towards a vote once.
- Compatible with existing keystone signing
- Does not leak your precise amount held. Instead, if you held
13 ZEC, this can decompose into 4 votes. A10ZEC vote, and 3 distinct1ZEC votes. These can each be submitted independently.
User Safety requirements
- Users do not change their custody method
- It is clear to the user what vote choice they are making
- It is clear to the user they are not spending their funds
- Network on keystone is displayed as testnet (EDIT: @nuttycom pointed out to me this is not possible)
- Recipient is themself
- Tx-fee is 0
Intuition
Here is a series of observations to realize that we can meet the above properties.
- We will make a vote that can be produced from a keystone signer, and “look like” an Orchard TX, but could not be run on mainnet. It can be verified with a similar ZKP to orchard. We collect these votes off-chain.
- Different nullifier circuit from mainnet
- Different network ID, Fee=0
- The hardware wallets do not verify the nullifier provided in the input actions. So we can off-chain alter the nullifier derivation, and your wallet can still sign it. Thus we make a new nullifier derivation for voting, that leaks nothing about your “true” nullifier.
- Our vote verification ZKP will check this new nullifier derivation
- We can use ordinal math to prescribe a discretization of your ZEC holdings. (i.e. 13 ZEC → 10, 1, 1, 1), and a separate nullifier computation per discretization.
- So a user can technically chose a separate vote per amount discretization.
- We can prove you held funds at the snapshot height, by setting the anchor height for note inclusion to be equal to the snapshot height.
- Exact same logic the chain uses in the ZKP.
- We can encode vote choice by setting the memo to be the user’s vote choice.
- Invalid memo’s wouldn’t count, but we could allow revoting.
- We make memo’s publicly knowable by
encrypting to the all-zeros outgoing viewing key
- The user’s wallet will handle randomly delaying when they send their votes to the collector, to preserve balance anonymity.
What do you see when you vote
When you vote, you should see that your doing a payment with:
- Network=Testnet (If possible)
- Fee=0
- Output has:
- To your own address
- Amount your voting with (e.g. 8)
- Memo: “YES for proposal 5”
User flow + Technical Design (sketch)
- I have 13 ZEC in one note that is held at the vote snapshot height
- I could have even spent it already!
- I go to a UI for voting. There are 11 votes going on. We call that “11 governance proposals” here.
- First the UI constructs the messages the user will sign over. This is done as follows
- The 13 ZEC is “bitwise” split, into eligible votes:
8 ZEC,4 ZEC,1 ZEC. Recall 13 is1101in binary!- Call these voting_shares, each indexed by their position in binary. So
voting_share_3for weight 8,voting_share_2for weight 4, andvoting_share_0for weight 1.
- Call these voting_shares, each indexed by their position in binary. So
- The UI derives three “Governance nullifiers”, by changing the Poseidon hash from
H(nullifier_key || rho)to instead effectively beH("gov_proposal_1" || voting_share_3 || nullifier_key || rho).- We prove correct nullifier derivation later in the ZKP. (Easy)
- UI then takes your voting choices, and makes outputs to your own address, with memos corresponding to your vote. It sets the “outgoing viewing key” to 0, so these memo’s and outputs are publicly visible.
- The 13 ZEC is “bitwise” split, into eligible votes:
- Then the UI gives your keystone 1 tx to sign per
voting_shareyou have (linking your votes across all proposals together)- Ideally, in the UI you fully separate your votes across tallies. That would prevent correlating your vote choice across proposals, but … would blow up the number of keystone signs you have to do.
- UI uses bridgetree to reconstruct the inclusion path for my note, at the anchor height.
- UI creates a ZKP very close to the standard Orchard payment ZKP. The modifications are:
- Nullifier is derived according to the above nullifier derivation
- Voting_share is an auxilliary input
- Output amount is tied to the “voting share” index selected. You prove that this is a valid choice given the input amount. Namely the bitwise decomposition of the note’s value has a
1in the selectedvoting_shareindex. - Memo is unconstrained, thats out-of-circuit logic.
- Nullifier is derived according to the above nullifier derivation
- Tallier receives the vote. It:
- Checks the ZKP is correct,
- Checks that network is correct, and fee=0
- Anchor height is correct snapshot height.
- Checks the signature (as-in Orchard)
- Ensures the nullifier hasn’t been used before in voting.
- Decrypts C_enc using OVK=0, and publishes (amount, memo) pair.
- First the UI constructs the messages the user will sign over. This is done as follows
Main downside: Number of signatures
The main downside of this is the number of signatures a keystone user will have to do. For 11 polling choices, and (say) 127 ZEC, they will have to do 7 signatures with linking their votes, or 77 signatures without linking their votes. Presumably, what will happen is a whale user who has a high hamming weight of tokens, will just not vote with their lower order token amounts.
The reason we face this problem is that although the keystone can sign many actions at once, it signs over the hash of all actions together. This is by intention for payments. Today this signature is not verified in the ZKP, and the hash is produced out of circuit. Our goal is to submit each action independently. We can achieve this by:
- verifying the signature in the ZKP, against a message in auxilliary input
- Proving the “vote action” hash is committed to within the auxilliary hash. (The ZCash hashing structure is well designed and would enable this)
The ZCash signatures are on Pallas, which is efficiently verifiable in Vesta (the proof system curve). So we should be able to do this. However, my initial proposal is to implement the full scheme without this. This is because there are not pre-existing RedPallas or Blake2b gadgets in halo2_gadgets.
Safety
The ZKP + Orchard signature verification proves:
- There exists a note in the Orchard commitment tree at
snapshot_height- with a value
vwhosevote_chunk_indexbit is1 - and I am authorized (knowledge of note secrets + a signature binding to vote choice)
- and I am producing a unique
nf_govfor this(proposal_id, vote_chunk_tag).
- with a value
- The output has value
1 << vote_chunk_index
Out of circuit, we prove no double voting by:
- showing uniqueness
nf_gov, just as we show uniqueness of nullifiers on mainnet.
Privacy:
- As this nullifier is derived distinctly from on mainnet, there is no leakage to any on-chain payment.
- We reveal the other fields of the outgoing note. However, all of the fields are uniformly random, as long as a random
diversifieris chosen.
- We do get vote correlation for “someone with 8 ZEC did {this vote selection}”, and “someone with 4 ZEC did {the same vote selection}”. Randomly delaying these vote submissions improve this publicly. Longer term (maybe before the next vote still), we adapt the ZKP to allow unbundling the votes, which will remove this concern.
Vote submission
We can keep vote submission as is done now, with all votes collated across a Tendermint service. We can do a very light amendment to this, to get the chain to verify all ZKP’s, and give live voting progress. Or we can keep it as a script that is done on top of this.
This gains censorship resistance of votes by having multiple nodes you can send to. (As is done today)
Governance design
Long term, there is concern that an attacker could borrow significant amounts of the ZEC supply. Right now this is hard to believe as the case.
The standard way to protect against this across crypto is to require that the user holds the ZEC for a given duration, to make a borrowing attack cost more. (“Staking”, “Voting lockup”, etc)
This seems like overkill for now as frequently transacting users could get removed from being able to vote. We would have to accept that, until a time where its appropriate to take on significant increases to SNARK complexity. Long term, this will be required, but becomes easier post-recursion (Ragu / Tachyon)
The only real borrowing attacks today would be borrowing from NEAR (Rhea markets) or a CEX, both would be very visible flows in event of attack. Most CEX’s let you short on exchange, but not cheaply borrow and withdraw, further reducing the 2026 risk of such an attack.
As this vote is not “in protocol”, we have time to react and correct. There have only been a couple DeFi borrow attacks in history, and all had double digits of supply available in liquid on-chain borrow markets and on-chain voting. Hence I don’t think they are comparable to ZCash in 2026.
Summary
Thoughts? I think this should safely be able to fix our problems, and be shippable quite quickly.