Skip to content

Commit 834c86d

Browse files
stackdumpclaude
andcommitted
Add maxChoices to VoteCast circuit — enforce choice < poll size
The circuit now constrains voteChoice < maxChoices via a public input. A poll with 3 choices rejects choice=200 at the proof level, not just server-side validation. The constraint uses subtraction + ToBinary to prove (maxChoices - choice - 1) is non-negative. Public inputs are now: pollId, voterRegistryRoot, nullifier, voteCommitment, maxChoices. On-chain contract stores maxChoices in the constructor and passes it to the verifier. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ea22a65 commit 834c86d

9 files changed

Lines changed: 42 additions & 20 deletions

File tree

internal/server/server.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -599,7 +599,7 @@ var circuitDescriptions = map[string]struct {
599599
"burn": {"ERC-20 burn: proves balance >= amount", []string{"preStateRoot", "postStateRoot", "from", "amount"}},
600600
"approve": {"ERC-20 approve: proves owner == caller", []string{"preStateRoot", "postStateRoot", "caller", "spender", "amount"}},
601601
"vestClaim": {"Vesting claim: proves ownership and available amount", []string{"preStateRoot", "postStateRoot", "tokenID", "caller", "claimAmount"}},
602-
"voteCast": {"ZK vote: proves voter eligibility and valid choice without revealing identity or choice", []string{"pollId", "voterRegistryRoot", "nullifier", "voteCommitment"}},
602+
"voteCast": {"ZK vote: proves voter eligibility and valid choice without revealing identity or choice", []string{"pollId", "voterRegistryRoot", "nullifier", "voteCommitment", "maxChoices"}},
603603
}
604604

605605
// handleCircuits lists available ZK circuits.
@@ -740,7 +740,7 @@ func (s *Server) handleBundle(w http.ResponseWriter, r *http.Request) {
740740

741741
genesisConfig := solidity.GenesisConfig{}
742742
if strings.HasPrefix(schema.Version, "Vote:") {
743-
genesisConfig.ConstructorArgs = "0 /* voterRegistryRoot */, address(0) /* verifier — deploy Verifier.sol first */"
743+
genesisConfig.ConstructorArgs = "0 /* voterRegistryRoot */, 10 /* maxChoices */, address(0) /* verifier — deploy Verifier.sol first */"
744744
}
745745
deployCode := solidity.GenerateGenesis(schema.Name, genesisConfig, solidity.DefaultAddresses())
746746

prover/circuits.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -287,19 +287,19 @@ func (c *VestingClaimCircuit) Define(api frontend.API) error {
287287
}
288288

289289
// VoteCastCircuit proves: "I am an eligible voter and my vote is valid" without revealing identity or choice.
290-
// Public inputs: pollId, voterRegistryRoot, nullifier, voteCommitment
290+
// Public inputs: pollId, voterRegistryRoot, nullifier, voteCommitment, maxChoices
291291
// Private inputs: voterSecret, voteChoice, voterWeight, pathElements[20], pathIndices[20]
292292
//
293293
// The voteCommitment = mimcHash(voterSecret, voteChoice) binds the choice to the voter's secret.
294294
// Since voterSecret is private and unknown to observers, the commitment cannot be brute-forced
295-
// even though voteChoice is only 8 bits. Tallying requires the voter to reveal their choice
296-
// after the poll closes.
295+
// even though voteChoice is only 8 bits.
297296
type VoteCastCircuit struct {
298297
// Public inputs
299298
PollID frontend.Variable `gnark:",public"`
300299
VoterRegistryRoot frontend.Variable `gnark:",public"`
301300
Nullifier frontend.Variable `gnark:",public"`
302301
VoteCommitment frontend.Variable `gnark:",public"`
302+
MaxChoices frontend.Variable `gnark:",public"`
303303

304304
// Private inputs
305305
VoterSecret frontend.Variable
@@ -329,8 +329,11 @@ func (c *VoteCastCircuit) Define(api frontend.API) error {
329329
expectedNullifier := mimcHash(api, c.VoterSecret, c.PollID)
330330
api.AssertIsEqual(c.Nullifier, expectedNullifier)
331331

332-
// 4. Vote range: proves choice fits in 8 bits (up to 256 options)
332+
// 4. Vote range: choice fits in 8 bits AND choice < maxChoices
333333
api.ToBinary(c.VoteChoice, 8)
334+
diff := api.Sub(c.MaxChoices, c.VoteChoice) // maxChoices - choice
335+
diffMinusOne := api.Sub(diff, 1) // must be >= 0 (i.e., choice <= maxChoices-1)
336+
api.ToBinary(diffMinusOne, 8) // proves non-negative
334337

335338
// 5. Vote commitment: binds choice to voter secret (blinded — can't brute-force)
336339
expectedCommitment := mimcHash(api, c.VoterSecret, c.VoteChoice)

prover/service.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,9 @@ func (f *ArcnetWitnessFactory) CreateAssignment(circuitName string, witness map[
239239
if assignment.VoteCommitment, err = goprover.ParseWitnessField(witness, "voteCommitment"); err != nil {
240240
return nil, err
241241
}
242+
if assignment.MaxChoices, err = goprover.ParseWitnessField(witness, "maxChoices"); err != nil {
243+
return nil, err
244+
}
242245
if assignment.VoterSecret, err = goprover.ParseWitnessField(witness, "voterSecret"); err != nil {
243246
return nil, err
244247
}

prover/verify.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,8 @@ func VerifyVoteCastWitness(p *Prover, witnessData map[string]string) error {
8484
// ValidateVoteCastPublicInputs checks that a voteCast proof's public inputs
8585
// match the expected poll parameters.
8686
func ValidateVoteCastPublicInputs(publicInputs []string, expectedPollID, expectedRegistryRoot string) error {
87-
if len(publicInputs) < 4 {
88-
return fmt.Errorf("voteCast requires 4 public inputs (pollId, registryRoot, nullifier, voteCommitment), got %d", len(publicInputs))
87+
if len(publicInputs) < 5 {
88+
return fmt.Errorf("voteCast requires 5 public inputs (pollId, registryRoot, nullifier, voteCommitment, maxChoices), got %d", len(publicInputs))
8989
}
9090

9191
// Public inputs order matches circuit definition: PollID, VoterRegistryRoot, Nullifier, VoteCommitment
@@ -109,14 +109,15 @@ func buildPublicWitness(circuitName string, publicInputs []string) (witness.Witn
109109
// Create a circuit assignment with only the public fields set
110110
switch circuitName {
111111
case "voteCast":
112-
if len(publicInputs) < 4 {
113-
return nil, fmt.Errorf("voteCast requires 4 public inputs, got %d", len(publicInputs))
112+
if len(publicInputs) < 5 {
113+
return nil, fmt.Errorf("voteCast requires 5 public inputs, got %d", len(publicInputs))
114114
}
115115
assignment := &VoteCastCircuit{
116116
PollID: parseBigIntOrZero(publicInputs[0]),
117117
VoterRegistryRoot: parseBigIntOrZero(publicInputs[1]),
118118
Nullifier: parseBigIntOrZero(publicInputs[2]),
119119
VoteCommitment: parseBigIntOrZero(publicInputs[3]),
120+
MaxChoices: parseBigIntOrZero(publicInputs[4]),
120121
}
121122
w, err := frontend.NewWitness(assignment, ecc.BN254.ScalarField(), frontend.PublicOnly())
122123
if err != nil {

public/bitwrap.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ function collectWitness(circuitName) {
261261
const leaf = mimcHash(voterSecret, voterWeight);
262262
const tree = MerkleTree.fromLeaves([leaf], 20);
263263

264-
return buildVoteCastWitness({ tree, voterIdx: 0, pollId, voterSecret, voteChoice, voterWeight });
264+
return buildVoteCastWitness({ tree, voterIdx: 0, pollId, voterSecret, voteChoice, voterWeight, maxChoices: 256n });
265265
}
266266
default:
267267
alert(`No witness builder for circuit: ${circuitName}`);

public/poll.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { buildVoteCastWitness } from './witness-builder.js';
66

77
// Current poll context
88
window.currentPollId = null;
9+
let currentPollData = null; // cached poll data from loadPoll
910
let selectedChoice = null;
1011

1112
// ============ Navigation ============
@@ -174,6 +175,7 @@ async function loadPoll(pollId) {
174175
if (!resp.ok) throw new Error('Poll not found');
175176
const data = await resp.json();
176177
const poll = data.poll;
178+
currentPollData = poll;
177179

178180
document.getElementById('vote-title').textContent = poll.title;
179181
document.getElementById('vote-desc').textContent = poll.description || '';
@@ -273,8 +275,9 @@ window.castVote = async function() {
273275
const leaf = mimcHash(voterSecret, voterWeight);
274276
const tree = MerkleTree.fromLeaves([leaf], 20);
275277

278+
const maxChoices = BigInt(currentPollData ? currentPollData.choices.length : 256);
276279
const witnessResult = buildVoteCastWitness({
277-
tree, voterIdx: 0, pollId, voterSecret, voteChoice, voterWeight
280+
tree, voterIdx: 0, pollId, voterSecret, voteChoice, voterWeight, maxChoices
278281
});
279282

280283
btn.innerHTML = '<span class="spinner"></span>Generating proof...';
@@ -317,6 +320,7 @@ window.castVote = async function() {
317320
witnessResult.witness.voterRegistryRoot,
318321
witnessResult.witness.nullifier,
319322
witnessResult.witness.voteCommitment,
323+
witnessResult.witness.maxChoices,
320324
],
321325
})
322326
});

public/witness-builder.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,11 +192,12 @@ export function buildTransferFromWitness({
192192
// voterSecret: voter's secret derived from wallet signature (BigInt)
193193
// voteChoice: the choice index (BigInt, 0-255)
194194
// voterWeight: voter's weight in the registry (BigInt)
195-
export function buildVoteCastWitness({ tree, voterIdx, pollId, voterSecret, voteChoice, voterWeight }) {
195+
export function buildVoteCastWitness({ tree, voterIdx, pollId, voterSecret, voteChoice, voterWeight, maxChoices }) {
196196
pollId = BigInt(pollId);
197197
voterSecret = BigInt(voterSecret);
198198
voteChoice = BigInt(voteChoice);
199199
voterWeight = BigInt(voterWeight);
200+
maxChoices = BigInt(maxChoices || 256);
200201

201202
// Voter registry root from Merkle tree
202203
const voterRegistryRoot = tree.root;
@@ -215,6 +216,7 @@ export function buildVoteCastWitness({ tree, voterIdx, pollId, voterSecret, vote
215216
voterRegistryRoot: fieldStr(voterRegistryRoot),
216217
nullifier: fieldStr(nullifier),
217218
voteCommitment: fieldStr(voteCommitment),
219+
maxChoices: fieldStr(maxChoices),
218220
voterSecret: fieldStr(voterSecret),
219221
voteChoice: fieldStr(voteChoice),
220222
voterWeight: fieldStr(voterWeight),

solidity/codegen.go

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ func (g *generator) generate() string {
3434
b.WriteString(" uint256[2] calldata _pA,\n")
3535
b.WriteString(" uint256[2][2] calldata _pB,\n")
3636
b.WriteString(" uint256[2] calldata _pC,\n")
37-
b.WriteString(" uint256[4] calldata _pubSignals\n")
37+
b.WriteString(" uint256[5] calldata _pubSignals\n")
3838
b.WriteString(" ) external view returns (bool);\n")
3939
b.WriteString("}\n\n")
4040
}
@@ -135,9 +135,10 @@ func (g *generator) generateConstructor() string {
135135
b.WriteString(" // ============ Constructor ============\n\n")
136136

137137
if g.isVoteSchema() {
138-
b.WriteString(" constructor(uint256 _voterRegistryRoot, address _verifier) {\n")
138+
b.WriteString(" constructor(uint256 _voterRegistryRoot, uint256 _maxChoices, address _verifier) {\n")
139139
b.WriteString(" contractOwner = msg.sender;\n")
140140
b.WriteString(" voterRegistryRoot = _voterRegistryRoot;\n")
141+
b.WriteString(" maxChoices = _maxChoices;\n")
141142
b.WriteString(" verifier = IVerifier(_verifier);\n")
142143
b.WriteString(" emit OwnershipTransferred(address(0), msg.sender);\n")
143144
b.WriteString(" }\n\n")
@@ -177,12 +178,13 @@ func (g *generator) generateVoteCastFunction() string {
177178
b.WriteString(" require(pollConfig == 1, \"poll not active\");\n")
178179
b.WriteString(" require(!nullifiers[_nullifier], \"already voted\");\n")
179180
b.WriteString("\n")
180-
b.WriteString(" // Verify ZK proof: public inputs are [pollId, voterRegistryRoot, nullifier, voteCommitment]\n")
181-
b.WriteString(" uint256[4] memory pubSignals;\n")
181+
b.WriteString(" // Verify ZK proof: public inputs are [pollId, voterRegistryRoot, nullifier, voteCommitment, maxChoices]\n")
182+
b.WriteString(" uint256[5] memory pubSignals;\n")
182183
b.WriteString(" pubSignals[0] = _pollId;\n")
183184
b.WriteString(" pubSignals[1] = voterRegistryRoot;\n")
184185
b.WriteString(" pubSignals[2] = _nullifier;\n")
185186
b.WriteString(" pubSignals[3] = _voteCommitment;\n")
187+
b.WriteString(" pubSignals[4] = maxChoices;\n")
186188
b.WriteString(" require(\n")
187189
b.WriteString(" verifier.verifyProof(_pA, _pB, _pC, pubSignals),\n")
188190
b.WriteString(" \"invalid ZK proof\"\n")
@@ -284,6 +286,7 @@ func (g *generator) generateStateVariables() string {
284286
if g.isVoteSchema() {
285287
b.WriteString("\n // ZK Voter Registry and Verifier\n")
286288
b.WriteString(" uint256 public voterRegistryRoot;\n")
289+
b.WriteString(" uint256 public maxChoices;\n")
287290
b.WriteString(" IVerifier public verifier;\n")
288291
b.WriteString(" mapping(uint256 => uint256) public voteCommitments; // nullifier => blinded vote commitment\n")
289292
}
@@ -337,6 +340,12 @@ func (g *generator) generateEvents() string {
337340
b.WriteString(" // ============ Events ============\n\n")
338341

339342
for _, action := range g.schema.Actions {
343+
// Vote-specific: castVote event matches the hand-written function emit
344+
if g.isVoteSchema() && action.ID == "castVote" {
345+
b.WriteString(" event CastVote(uint256 epoch, uint256 seq, uint256 indexed nullifier, uint256 voteCommitment);\n")
346+
continue
347+
}
348+
340349
params := g.inferEventParams(action)
341350
// Add epoch and seq to all events for debugging
342351
if params != "" {

solidity/testgen.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ func (g *testGenerator) generate() string {
3838
b.WriteString("\n function setUp() public {\n")
3939
b.WriteString(" // Deploy a mock verifier that always returns true\n")
4040
b.WriteString(" address mockVerifier = address(new MockVerifier());\n")
41-
b.WriteString(fmt.Sprintf(" token = new %s(0, mockVerifier);\n", contractName))
41+
b.WriteString(fmt.Sprintf(" token = new %s(0, 10, mockVerifier);\n", contractName))
4242
b.WriteString(" }\n\n")
4343
} else {
4444
b.WriteString(" function setUp() public {\n")
@@ -63,7 +63,7 @@ func (g *testGenerator) generate() string {
6363
b.WriteString(" uint256[2] calldata,\n")
6464
b.WriteString(" uint256[2][2] calldata,\n")
6565
b.WriteString(" uint256[2] calldata,\n")
66-
b.WriteString(" uint256[4] calldata\n")
66+
b.WriteString(" uint256[5] calldata\n")
6767
b.WriteString(" ) external pure returns (bool) {\n")
6868
b.WriteString(" return true;\n")
6969
b.WriteString(" }\n")
@@ -232,7 +232,7 @@ func (g *testGenerator) generateInvariantTests(contractName string) string {
232232

233233
b.WriteString(" function setUp() public {\n")
234234
if strings.HasPrefix(g.schema.Version, "Vote:") {
235-
b.WriteString(fmt.Sprintf(" token = new %s(0, address(new MockVerifier()));\n", contractName))
235+
b.WriteString(fmt.Sprintf(" token = new %s(0, 10, address(new MockVerifier()));\n", contractName))
236236
} else {
237237
b.WriteString(fmt.Sprintf(" token = new %s();\n", contractName))
238238
}

0 commit comments

Comments
 (0)