Skip to content

Commit 67867fc

Browse files
stackdumpclaude
andcommitted
Phase B / B5.6: JS witness builders for v3 circuits
Adds buildVoteCastHomomorphicWitness and buildTallyDecryptWitness to public/witness-builder.js, plus a parity test that runs the JS builders through node, ingests the JSON output in Go, and runs groth16.Prove + groth16.Verify on both circuits. The parity test is the contract between public/witness-builder.js and prover/*_gen.go: any drift in field naming, ordering, or value derivation surfaces as a Prove/Verify failure before reaching a browser. Vote witness derives nullifier and one-hot V[K] from inputs and encrypts each bin under pkCreator using the existing Pedersen primitive in pedersen.js. Per-bin randomness defaults to mimcHash(secret, pollId, j) reduced mod the BabyJubJub subgroup order so resubmissions are deterministic; production callers should pass real randomness. Tally witness recomputes pk = G·sk in JS, decodes aggregate ciphertexts, and decrypts each bin via small-range DL search bounded at the circuit's 16-bit tally cap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent b56ab1d commit 67867fc

3 files changed

Lines changed: 498 additions & 0 deletions

File tree

prover/witness_v3_parity_test.go

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
package prover
2+
3+
import (
4+
"encoding/hex"
5+
"encoding/json"
6+
"math/big"
7+
"os"
8+
"os/exec"
9+
"path/filepath"
10+
"testing"
11+
12+
"github.com/consensys/gnark-crypto/ecc"
13+
tedwards "github.com/consensys/gnark-crypto/ecc/bn254/twistededwards"
14+
"github.com/consensys/gnark/backend/groth16"
15+
"github.com/consensys/gnark/frontend"
16+
gtw "github.com/consensys/gnark/std/algebra/native/twistededwards"
17+
)
18+
19+
// TestWitnessV3Parity runs the JS witness builder, ingests its output,
20+
// builds the matching gnark assignment, and runs Prove + Verify on
21+
// both v3 circuits. This is the contract between
22+
// public/witness-builder.js and prover/{vote,tally}_*_gen.go: any
23+
// drift in field naming, ordering, or value derivation surfaces here
24+
// before it gets shipped to a browser.
25+
//
26+
// Skipped if `node` is unavailable.
27+
func TestWitnessV3Parity(t *testing.T) {
28+
if testing.Short() {
29+
t.Skip("v3 witness parity test compiles two large circuits; skip in short mode")
30+
}
31+
if _, err := exec.LookPath("node"); err != nil {
32+
t.Skip("node not installed; skipping JS witness parity test")
33+
}
34+
35+
root := findRepoRoot(t)
36+
cmd := exec.Command("node", "public/witness_v3_parity.mjs")
37+
cmd.Dir = root
38+
out, err := cmd.Output()
39+
if err != nil {
40+
stderr := ""
41+
if ee, ok := err.(*exec.ExitError); ok {
42+
stderr = string(ee.Stderr)
43+
}
44+
t.Fatalf("node script failed: %v\n%s", err, stderr)
45+
}
46+
47+
var dump struct {
48+
PollID string `json:"pollId"`
49+
VoterSecret string `json:"voterSecret"`
50+
VoterWeight string `json:"voterWeight"`
51+
Choice int `json:"choice"`
52+
MaxChoices int `json:"maxChoices"`
53+
SkCreator string `json:"skCreator"`
54+
PkCreator string `json:"pkCreator"`
55+
56+
VoteCastHomomorphic struct {
57+
Circuit string `json:"circuit"`
58+
Witness map[string]string `json:"witness"`
59+
} `json:"voteCastHomomorphic"`
60+
61+
TallyDecrypt struct {
62+
Circuit string `json:"circuit"`
63+
Witness map[string]string `json:"witness"`
64+
Tallies []int `json:"tallies"`
65+
} `json:"tallyDecrypt"`
66+
}
67+
if err := json.Unmarshal(out, &dump); err != nil {
68+
t.Fatalf("decode JS output: %v\nraw: %s", err, string(out))
69+
}
70+
71+
t.Run("voteCastHomomorphic_8", func(t *testing.T) {
72+
assertVoteCastHomomorphicWitness(t, dump.VoteCastHomomorphic.Witness)
73+
})
74+
75+
t.Run("tallyDecrypt_8", func(t *testing.T) {
76+
if len(dump.TallyDecrypt.Tallies) != TallyDecryptChoices {
77+
t.Fatalf("expected %d tallies, got %d", TallyDecryptChoices, len(dump.TallyDecrypt.Tallies))
78+
}
79+
assertTallyDecryptWitness(t, dump.TallyDecrypt.Witness)
80+
})
81+
82+
// Optional: leave the dump on disk for manual debugging.
83+
_ = os.WriteFile(filepath.Join(t.TempDir(), "v3-witness.json"), out, 0o644)
84+
}
85+
86+
func assertVoteCastHomomorphicWitness(t *testing.T, w map[string]string) {
87+
t.Helper()
88+
c := &VoteCastHomomorphicCircuit_8{}
89+
c.PollID = mustField(t, w, "pollId")
90+
c.VoterRegistryRoot = mustField(t, w, "voterRegistryRoot")
91+
c.Nullifier = mustField(t, w, "nullifier")
92+
c.MaxChoices = mustField(t, w, "maxChoices")
93+
c.PkCreator = pointFromMap(t, w, "pkCreator")
94+
c.VoterSecret = mustField(t, w, "voterSecret")
95+
c.VoterWeight = mustField(t, w, "voterWeight")
96+
97+
for j := 0; j < VoteCastHomomorphicChoices; j++ {
98+
c.V[j] = mustField(t, w, fmtIdx("V", j))
99+
c.R[j] = mustField(t, w, fmtIdx("R", j))
100+
c.CtA[j] = pointFromMap(t, w, fmtIdx("CtA", j))
101+
c.CtB[j] = pointFromMap(t, w, fmtIdx("CtB", j))
102+
}
103+
for i := 0; i < homomorphicMerkleDepth; i++ {
104+
c.PathElements[i] = mustField(t, w, fmtIdx("pathElement", i))
105+
c.PathIndices[i] = mustField(t, w, fmtIdx("pathIndex", i))
106+
}
107+
108+
proveAndVerify(t, "voteCastHomomorphic_8", &VoteCastHomomorphicCircuit_8{}, c)
109+
}
110+
111+
func assertTallyDecryptWitness(t *testing.T, w map[string]string) {
112+
t.Helper()
113+
c := &TallyDecryptCircuit_8{}
114+
c.PkCreator = pointFromMap(t, w, "pkCreator")
115+
c.SkCreator = mustField(t, w, "skCreator")
116+
for j := 0; j < TallyDecryptChoices; j++ {
117+
c.A[j] = pointFromMap(t, w, fmtIdx("A", j))
118+
c.B[j] = pointFromMap(t, w, fmtIdx("B", j))
119+
c.Tallies[j] = mustField(t, w, fmtIdx("Tallies", j))
120+
}
121+
122+
proveAndVerify(t, "tallyDecrypt_8", &TallyDecryptCircuit_8{}, c)
123+
}
124+
125+
func proveAndVerify(t *testing.T, name string, schema, assignment frontend.Circuit) {
126+
t.Helper()
127+
p := NewProver()
128+
cc, err := p.CompileCircuit(name, schema)
129+
if err != nil {
130+
t.Fatalf("compile %s: %v", name, err)
131+
}
132+
full, err := frontend.NewWitness(assignment, ecc.BN254.ScalarField())
133+
if err != nil {
134+
t.Fatalf("witness %s: %v", name, err)
135+
}
136+
proof, err := groth16.Prove(cc.CS, cc.ProvingKey, full)
137+
if err != nil {
138+
t.Fatalf("prove %s: %v", name, err)
139+
}
140+
pubW, err := full.Public()
141+
if err != nil {
142+
t.Fatalf("public witness %s: %v", name, err)
143+
}
144+
if err := groth16.Verify(proof, cc.VerifyingKey, pubW); err != nil {
145+
t.Fatalf("verify %s: %v", name, err)
146+
}
147+
}
148+
149+
func mustField(t *testing.T, m map[string]string, key string) *big.Int {
150+
t.Helper()
151+
s, ok := m[key]
152+
if !ok {
153+
t.Fatalf("witness missing key %q\nkeys: %v", key, mapKeys(m))
154+
}
155+
n, ok := new(big.Int).SetString(s, 10)
156+
if !ok {
157+
t.Fatalf("witness[%q] = %q is not a decimal big.Int", key, s)
158+
}
159+
return n
160+
}
161+
162+
func pointFromMap(t *testing.T, m map[string]string, prefix string) gtw.Point {
163+
t.Helper()
164+
xKey := prefix + ".X"
165+
yKey := prefix + ".Y"
166+
return gtw.Point{
167+
X: mustField(t, m, xKey),
168+
Y: mustField(t, m, yKey),
169+
}
170+
}
171+
172+
func mapKeys(m map[string]string) []string {
173+
out := make([]string, 0, len(m))
174+
for k := range m {
175+
out = append(out, k)
176+
}
177+
return out
178+
}
179+
180+
func fmtIdx(prefix string, i int) string {
181+
// Avoids fmt.Sprintf overhead in a hot test helper.
182+
return prefix + itoa(i)
183+
}
184+
185+
func itoa(i int) string {
186+
if i == 0 {
187+
return "0"
188+
}
189+
digits := make([]byte, 0, 4)
190+
for i > 0 {
191+
digits = append(digits, byte('0'+i%10))
192+
i /= 10
193+
}
194+
for l, r := 0, len(digits)-1; l < r; l, r = l+1, r-1 {
195+
digits[l], digits[r] = digits[r], digits[l]
196+
}
197+
return string(digits)
198+
}
199+
200+
// confirm helper imports stay in use even when test is skipped.
201+
var (
202+
_ = hex.EncodeToString
203+
_ = (*tedwards.PointAffine)(nil)
204+
)

0 commit comments

Comments
 (0)