Proof boundaries in a minimal homomorphic tally for token weighted voting

Motivation

Homomorphic tallying is easy to summarize: encrypt each ballot, add the ciphertexts, and decrypt the sum. The harder part is specifying exactly what a proof must bind for that sum to represent an election rather than merely a valid group computation.

I built Commitra, a solo research implementation of token weighted voting, and exercised it end to end on Ethereum Sepolia. It uses two Groth16 circuits, BabyJubJub ElGamal, browser side proving, a relayer, and a fixed tally batch of 100. The result is both a worked minimal implementation and a concrete map of the gap between what the implementation does, what its proofs constrain, and what still depends on an honest client or coordinator.

The full source, specification, build instructions, artifact hashes, and recorded Sepolia runs are here:

Three independent code-review passes, with write-ups not published, converged on the proof boundaries below. I used their shared findings to rewrite the public specification around what is enforced rather than what the design intended.

Prior art and scope

MACI focuses on anti collusion and has a different threat model; the reference point here is MACI v3.0.0.
Its repository was archived on 2026-08-19.
Vocdoni’s DAVINCI is the closest prior art for homomorphic ballot aggregation and aggregate decryption.
This post is not a comparison or novelty claim. It uses one minimal implementation to make its proof boundaries and failure modes inspectable.

What was implemented

For each voteId, the server holds an EOA to weight snapshot. A voter signs with a wallet, derives a BabyJubJub key and private secrets, commits to the key and weight in a Merkle leaf, and registers that leaf under a coordinator accepted root.

In the browser, the standard client encrypts weight for one of YES, NO, or ABSTAIN and encrypts zero for the other two choices. It then generates a Groth16 vote proof. The coordinator relays the proof onchain, so participant EOAs are absent from the intended vote submission path and participants pay no gas.

After closure, the implementation pads the private DB batch to 100 ciphertext sets, homomorphically sums them, decrypts the aggregate with a bounded BSGS discrete log, generates a tally proof, and stores the three result signals onchain. The implemented tally path decrypts the aggregate rather than individual ballots, but the current coordinator holds a single private key and could technically decrypt a ballot.

The two circuit sources were already committed by 2025-12-31 in the author’s development repository. The repository’s PROVENANCE document separates that development record from the later public release and lists the deployment addresses and artifact hashes.

What the proofs actually constrain

The following tables describe the current circuits, not the intended protocol.

Vote proof

Constrained today Not constrained today
The leaf is under a root accepted by the contract The accepted root came from the intended EOA and weight snapshot
The leaf internally commits to a BabyJubJub public key and (weight, secret_weight) The submitted EOA is owned by the requester, or the committed weight equals its snapshot weight
voterID and nullifier are derived from the private secrets supplied to the circuit The nullifier is uniquely determined by the leaf or signing key
The exact nullifier is spendable only once per voteId, enforced onchain One vote per voter
The EdDSA key committed in the leaf signed a hash that includes ciphertext hash, voteId, chainId, voterID, and nullifier The ciphertexts satisfy the ElGamal equations
No corresponding constraint Each plaintext is in {0, weight}, and exactly one choice carries the full weight

Separately from the circuit, the server recomputes the Poseidon hash of the submitted ciphertext coordinates and checks it against publicSignals[3] before relaying.

The server now rejects noncanonical, off curve, small subgroup, and identity C1 points before relaying. This is a server mitigation; the circuit gap remains. A valid subgroup point can still encode a plaintext that the proof does not constrain.

The double vote boundary is similarly narrow: exact nullifier reuse is rejected onchain, but the nullifier is not bound to the leaf. A modified client can choose a fresh private nullifier secret and generate another valid proof from the same leaf.

Tally proof

Constrained today Not constrained today
A private batch contains exactly 100 ciphertext sets and hashes to encryptedBatchHash The batch is the exact ordered set of ballots and dummies submitted onchain
The public aggregate group elements are the homomorphic sum of that private batch Dummy hashes represent zero weight padding; the contract records only that registerDummyVotes was called once
Each public tally scalar maps to the decrypted aggregate group element under the configured public key The scalar is the unique canonical integer in the supported tally range, or the three results satisfy a range and total bound
coordinatorPubkey is derived from the private key in the circuit and matched against configured coordinates by the contract The proof is bound to voteId, chainId, or a contract deployment

Per voteId result storage does not solve the domain problem. Since voteId and deployment identity are absent from tally public inputs, a proof can be replayed to another closed, dummy registered voteId when the remaining state checks pass.

There is also an integer representation boundary. The tally circuit proves that a supplied scalar maps to the decrypted BabyJubJub point. It does not enforce a unique canonical scalar within the intended voting range, and the contract stores the result signals without range or sum checks.

Four pitfalls that were easy to miss

1. Hash binding is not semantic validity

Hashing ciphertext coordinates inside a proof binds the proof to those coordinates. It does not prove that they are an encryption of the committed weight, that exactly one option was selected, or even that the points were valid before the server side check was added.

ElGamal encryption requires only the public key. A modified client does not need the coordinator private key to encrypt an arbitrary value. Point validation removes one malformed input class, but arbitrary plaintexts on valid points remain a circuit problem.

2. Deterministic client behavior is not a circuit constraint

The standard client deterministically derives its nullifier secret from a wallet signature, so an honest repeat attempt produces the same nullifier and fails onchain. The circuit, however, accepts the nullifier secret as a free private input. The proof verifies derivation from the supplied secret, not that the secret is uniquely tied to the leaf.

This distinction turns exact nullifier single use into something weaker than one voter one vote.

3. A correct tally of some batch is not the tally of the submitted set

The tally proof checks a Poseidon chained private batch, its homomorphic sum, and its aggregate decryption representation. The contracts do not reconstruct that batch from VoteSubmitted and dummy events. The coordinator therefore chooses the witness batch.

Dummy registration has the same boundary: the voting contract accepts arbitrary hashes, emits them, and sets a boolean. Tally finalization checks the boolean, not the padding contents or count.

Adding voteId storage around the result does not bind the proof to that vote, and group equality alone does not select one canonical integer result. Batch identity, proof domain, and scalar range are separate statements.

4. Offchain privacy depends on the observer

The relayed transaction path keeps participant EOAs out of vote calldata and events. That is a concrete property against chain observers.

It is not server unlinkability. The server stores EOA and weight snapshot rows and can correlate EOA, leaf, and later public commitment through token order, timing, connection metadata, and logs. It also does not currently verify ownership of the submitted EOA. The wallet signature in the standard flow derives private credentials; it is not sent as an authentication proof to the server.

Direction: authenticated, once per EOA admission in the near term server hardening phase, followed by circuit level eligibility and weight binding.

Current trust assumptions

The single key coordinator trust model is more than possession of the ElGamal secret key. An authorized coordinator can currently:

  • decrypt individual ciphertexts, although the implemented tally path decrypts only the aggregate
  • decline to relay a ballot
  • approve an arbitrary root, including zero, without binding it to the snapshot
  • close a vote immediately and irreversibly because there is no onchain deadline, quorum, or minimum count
  • preserve every previously accepted root because there is no root revocation function
  • set the dummy registration flag with arbitrary hashes or an empty array
  • choose the private tally batch
  • replay a tally proof across compatible voteIds or deployments
  • publish tally scalars without a canonical range or total sum constraint

The deployment owner is a permanent authorized coordinator, and there is no ownership transfer function. Constructor supplied verifier and contract addresses are checked for nonzero values, not for code identity or correspondence to the published artifacts.

There are two further assumptions:

  • Both Groth16 proving keys were produced with a single phase 2 contribution by the author. If its randomness were retained, false proofs could be forged. A real circuit release needs a multi party ceremony.
  • The system is not receipt free. A voter can record and reveal ElGamal randomness to prove a vote. Anti collusion is outside this implementation’s scope.

A recent server hardening pass added Cloudflare aware rate limiting, authentication failure throttling, generic 500 error responses, a locally pinned snarkjs build, handling for exactly 100 real votes, and validation of all six ciphertext points. These close operational paths, but do not change the circuit statements above.

Demo as a self verification device

The request based Sepolia demo is an honest client flow demonstration, not an adversarial election. The useful way to run it is to bring several wallets you control, record each random demo weight and choice, and compare your sums with the finalized onchain result.

Comparing your sums with the finalized totals detects omissions or substitutions that change the participant known totals; it does not establish exact batch identity. A different batch can produce the same aggregate.

Demo access is available by DM at @0xgumi. The password is not public.

Directions

These are problem statements, not delivery commitments.

Vote circuit input integrity

The next vote circuit needs to constrain prime order ElGamal construction, plaintexts in {0, weight}, one choice carrying the full weight, a nullifier uniquely derived from leaf bound key material, and eligibility and weight bound to an authenticated set.

Canonical tally binding

The tally statement needs an exact ordered submitted set, canonical dummy handling, vote and deployment domain separation, and a unique scalar range and total bound shared by the circuit and contract.

Threshold decryption

Aggregate only decryption becomes an enforced property only when no single keyholder can decrypt a ballot. That requires distributed key generation, partial decryption, and proofs for the shares.

Segment splitting for large weights

Raw 18 decimal token balances make a single BSGS range impractical. The open problem is preserving exact weight while keeping each discrete log range tractable and the circuit representation sound.

Confidential outcomes

Some settings need only a threshold outcome while exact totals remain undisclosed. The open problem is proving that outcome without weakening the aggregation, decryption, quorum, and range relations.

Cross chain settlement

A proof delivered to another chain must authenticate more than its bridge origin. It must be bound to the source vote, deployment, and canonical submitted set before a target chain can safely execute from it.

Questions

  1. For browser side Groth16 proving, what is the most economical way to constrain BabyJubJub ElGamal point validity together with one choice {0, weight} allocation? Is constrained re encryption still the practical baseline?
  2. Should a per vote nullifier be derived directly from a leaf bound key secret, or is there a cleaner construction that removes the free secret while preserving unlinkability across votes?
  3. What is the best gas and proof size tradeoff for binding a tally proof to the exact ordered set of submitted ciphertexts? For a batch of 100, would you favor an onchain accumulator, a stored hash array, or a different commitment bridged into the circuit?

The implementation and exact proof scope are in the repository. Feedback on these three boundaries would be especially useful.