New Token Holder Voting Implementation

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. A 10 ZEC vote, and 3 distinct 1 ZEC 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 will also check that the true nullifier hasn’t been spent by snapshot height
  • 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
  • We prove no double-voting, by separately including a statement in the ZKP to show that your “true” nullifier has not been spent on-chain.
  • 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”

System setup

We make a new vote by fixing a snapshot height, that aligns with a zcash anchor height. We off-chain take the list of all nullifiers, and create a sorted-by-key merkle tree of them. (So we can prove exclusion against this)

The vote publishes: (snapshot height, Voting/polling choices, nullifier mt tree root, note commitment tree root). People have to verify the two tree roots, by snapshot height, when approving the vote setup.

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 is 1101 in binary!
        • Call these voting_shares, each indexed by their position in binary. So voting_share_3 for weight 8, voting_share_2 for weight 4, and voting_share_0 for weight 1.
      • The UI derives three “Governance nullifiers”, by changing the Poseidon hash from H(nullifier_key || rho) to instead effectively be H("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.
    • Then the UI gives your keystone 1 tx to sign per voting_share you 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 queries a “PIR” service (via a binary search, won’t detail here) to privately find an “exclusion proof” that their nullifier has not been spent on mainnet. No RPC learns your nullifier
    • UI creates a ZKP very close to the standard Orchard payment ZKP. The modifications are:
      • Gov_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 1 in the selected voting_share index.
      • true_nullifier has not appeared on-chain (checking a nullifier exclusion tree)
      • Memo is unconstrained, thats out-of-circuit logic.
    • 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.

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.

Note if your not using Keystone, should just be one signature.

Safety

The ZKP + Orchard signature verification proves:

  • There exists a note in the Orchard commitment tree at snapshot_height
    • with a value v whose vote_chunk_index bit is 1
    • and I am authorized (knowledge of note secrets + a signature binding to vote choice)
    • and I am producing a unique nf_gov for this (proposal_id, vote_chunk_tag).
  • 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 diversifier is 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.

17 Likes

Going thru th voting process right now and it’s brutal, this would be a godsend for getting participation up

4 Likes

Just for reference, there’s also this from @daira / @joshs / @nuttycom / @str4d. I haven’t spent a ton of time studying it but I think it would be good to surface here.

4 Likes

@nuttycom pointed out that Keystone’s cannot sign on Testnet. My mistake, thank you! I’m a bit confused as to why this is the case, but this means that we have to adapt the design if we don’t users to be at risk of phishing attacks. (This design would not be spendable on-chain from an honest FE, but as a user you have no idea what your correct nullifier is. So you could be fooled. If you accepted mainnet funds to an address that is not your own, then even w/ fee=0, you could get stolen from with a colluding miner)

Thus if we can’t rely on telling users “Look for the tx being a testnet tx”, then we can instead make the output notes be to yourself in what is shown in the keystone. @str4d pointed out to me, that We already use “encrypt to the all-zeros OVK” for publicly-decryptable outputs. Which achieves the same result as the proposed design. (Everyone could see the outbound note, which includes amount, and the vote choice memo)

EDIT: I’ve updated the original post accordingly

3 Likes

i think sth that could help already a lot is if some popular wallet implemented the coin voting into a mobile wallet(like Zashi?). then you wouldnt need to move coins as you would already have the seed in the wallet and would just have to choose vote weight for each question/answer and let the wallet calculate what it needs to.

current problems comes from having to vote for each question 1 by 1 and having to insert seedphrase for each of them because the voting app isnt a wallet by itself.

4 Likes

I haven’t studied your proposal in depth, but here are a few quick off-the-cuff responses from me, speaking personally:

  • Oh yeah, the current UX is brutal. It is even harder and more confusing than spending shielded Sprout funds was in 2017! :rofl: (Which makes me even more impressed at how many ballots and how many coins get cast in these polls.) It’s great that Hanh and ZCG have done so much to make this possible, and improved it many times, but it is time for another UX iteration so that it can become accessible to more users.
  • It would be a huge UX upgrade if, as you suggest here, there were only one cutoff date—the closing of the eligibility window—and not two: one for the opening of the window and one for the closing. In particular, most users do not understand about notes—they only understand about balances—so it is confusing to them if they had a Zcash balance at the cutoff date but it is not eligible to vote. (I’m aware that this is a modest cryptography challenge.)
  • I like the powers-of-ten-bucketing part. It corresponds with the powers-of-ten-bucketing for privacy in our current Crosslink design.
  • I love the idea of adding optional time-weighting of votes! Not just to defend against the one “borrow-vote-return” attack you mention, but more generally because sometimes when people are looking at poll/petition results, they want to see long-termer’s opinions more than short-termer’s opinions. I, for one, would love to see the results of a coin-holder’s poll/petition which simply included only Zcash balances that have been held for at least 2 years. Old-timers are special, and I want to be able to hear their voices, not drowned out by the voices of newcomers. (Although I also want to hear the voices of newcomers.) See also this paper by Chris Berg (member of the Shielded Labs Board of Directors, by the way) which, among other things, proves that time-weighting is the only possible mechanism that can give small-timers (minnows) an advantage over big-timers (whales). (I’m aware this is a big hairy cryptography challenge when combined with the “only one cutoff” UX goal above.)
  • I agree with Zerodartz that top-tier UX can only come from integration into a wallet. If a user has to install a different app than the app that they already have, that’s already a big UX hurdle. But this proposal could help future wallet-builders even if this proposal itself is separate.

In general, I’m really excited that you’re interested in this project!

11 Likes

agreed

I think there is a way to double-vote: Suppose I have 1 ZEC and send it to myself 100 times. Now I have 100 ~1 ZEC notes in the note commitment tree, all but the last of which will be considered spent by the Zcash consensus rules since their nullifiers have been revealed. But all 100 of these notes will have unique “governance nullifiers”, so I can use each of them to vote once, effectively amplifying my voting power 100x. To prevent this, you would need to also prove that the notes’ real nullifiers are not in the snapshot nullifier set, i.e. they are unspent. To do that efficiently you could sort the snapshot nullifier set, build a Merkle tree over each pair of nullifiers in the sorted list (basically one leaf in the tree for each “gap”) and then prove non-membership by showing you have a Merkle path down to the gap the actual nullifier falls in.

2 Likes

I share @earthrise concern about double voting: since the protocol does not reveal the original nullifier and does not contain a proof that it hasn’t been used, it seems it can be double spent, ie double voted.
Also, regarding the existing experience. We know it is painful.

  1. I didn’t realize that most elections would end up being a combination of multiple questions. Currently each question is treated as an independent election (with its own start/end/etc.). The system is redesigned to allow for a a single question to include multiple options and values. For instance, the NU7 sentiment polling would be represented by the following file:
    zcv/tests/nu7.yml at main · hhanh00/zcv · GitHub

You would vote once, thus making the process 11x faster than it is now.

The entire voting ecosystem is being redesigned and rewritten. You can follow the progress in this repository: GitHub - hhanh00/zcv
It takes time since it involves making a new election creator tool, a new voting app, a new server app and a new verification/couting app.

  1. Moving funds is not necessary but recommended because you are essentially input your seed into a third-party wallet that uses a different shielding protocol.
    The voting protocol does not reveal your seed phrase on chain. In fact, it is very similar to the existing Orchard protocol except that it derives the nullifier differently and adds a proof that the real (now hidden) nullifier hasn’t be used. But the code has only been audited by one organization (Least Authority) and we encourage you to move your funds as a precaution because it may still contain vulnerabilities.
5 Likes

Ah you’re right, my bad. We would also need to prove that your true nullifier didn’t appear on chain. I had initially intended a design with an on chain tx to handle this, then tried to get away without doing that.

We can fix this via making a sorted merkle tree of all the nullifiers on chain at the snapshot height. Then the wallet will produce the valid nullifier as an auxiliary input. the user can fetch an exclusion proof that their nullifier is not in the tree via PIR. (We’ve actually already built out single-server PIR for the nullifier database, as I think it’s the right design elsewhere. We can make this the first usage)

Then your zkp will have two merkle paths it proves, one for note commitment, and a second for nullifier exclusion. This is actually similar to how zk payments / nullifier mgmt already work on solana (Light protocol)

Per Hanhs message, sounds like this (minus PIR) is already being done ?

I’ve edited the OP, I’ll make some diagrams to make this easier to understand as well!

2 Likes

A requirement for voting imo is that a keystone user can do it all without touching their mnemonic

Eg if we had a new app that’s fine, as long as you can vote with your existing hardware wallet

2 Likes

The hw wallet doesn’t sign “blindly”. It would have to implement the coin voting signing algorithm and compute the hash itself.

Agreed, thats why we should re-use the existing on-chain signing algo, but have the tx be credibly unspendable to the user!

That seems contradictory. Could you elaborate on how it could be achieved?

You show the user a tx which:

  • Has outputs to themself
  • fee=0
  • (if keystone supports it) network=testnet

Then we instruct keystone users to see that all outputs are going to themself, thus they could not be getting phished.

I’m surprised keystone doesn’t support network=testnet (per @nuttycom) , that would make this much easier to communicate its not being phished to users.

Without network=testnet it is true, that its not credibly unspendable from just what you see on device. But it would be a self-pay at worst. (And you see the fee=0 to get higher confidence it won’t get in)

You would revealing the real nullifier and there is an issue with linkability between ballots cast on different elections.

So we replace the nullifier that is shown to the user with one that is derived just for each vote. (The keystone does not validate this) Thus we don’t have a linkability concern. We derive the signed over nullifier to be unique for which proposal is being voted upon.

We can then do a separate statement in the ZKP to show that your real nulifier hasn’t been spent on-chain

The nullifier would still be needed in the tx in order for a node to compute the sighash and verify the signature.

yes, so we reveal the nullifier thats unique to each vote. (And keep the “true” nullifier on-chain hidden)

EDIT: Made a quick diagram that may make this easier

1 Like

So you pass the vote specific nullifier to the HW app? I dunno if the KS checks them, but the Ledger app does. Currently the sighash for coin voting is deliberately different than the regular one to prevent replay attacks.

Generally speaking, the issue is that the coin voting app needs to scan for orchard notes. We used to accept a viewing key but that still would require pairing with the KS. Therefore I wasn’t sure it would make users more comfortable.

The recommended way (moving funds out and back), is tedious but is safe as long as you perform it properly.

1 Like