Skip to content

Commit 9152e41

Browse files
stackdumpclaude
andcommitted
Add ZK Polls: anonymous voting with Groth16 proofs
Vote template (ERC pattern), VoteCast ZK circuit (14.6k constraints), poll lifecycle API with server-side proof verification, voting UI at /poll, and on-chain governance contracts with IVerifier integration. - erc/vote.go: Vote template with voterRegistry, nullifiers, tallies - prover/circuits.go: VoteCastCircuit (Merkle inclusion + nullifier binding) - internal/server/polls.go: CRUD + vote endpoints with ZK verification - internal/server/auth.go: EIP-191 wallet signature recovery (secp256k1) - internal/server/ratelimit.go: IP + wallet rate limiting (5/hr) - solidity/codegen.go: IVerifier interface, proof-gated castVote - public/poll.html + poll.js: Create/vote/results UI + Foundry bundle download - Foundry tests: 8/8 pass including double-vote and inactive-poll reverts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e3c9869 commit 9152e41

21 files changed

Lines changed: 2251 additions & 59 deletions

erc/erc000.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const (
1717
StandardERC04626 Standard = "ERC-04626"
1818
StandardERC05725 Standard = "ERC-05725"
1919
StandardBridge Standard = "Bridge"
20+
StandardVote Standard = "Vote"
2021
)
2122

2223
// TokenMetadata holds common token metadata.

erc/vote.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package erc
2+
3+
import (
4+
"github.com/stackdump/bitwrap-io/arc"
5+
"github.com/stackdump/bitwrap-io/internal/metamodel"
6+
)
7+
8+
// Vote represents a ZK Poll voting template.
9+
type Vote struct {
10+
BaseTemplate
11+
}
12+
13+
// NewVote creates a new ZK Poll voting template.
14+
func NewVote(name string) *Vote {
15+
schema := metamodel.NewSchema(name)
16+
schema.Version = "Vote:1.0.0"
17+
18+
// States
19+
schema.AddState(metamodel.State{ID: "voterRegistry", Type: "map[uint256]uint256", Exported: true})
20+
schema.AddState(metamodel.State{ID: "nullifiers", Type: "map[uint256]bool", Exported: true})
21+
schema.AddState(metamodel.State{ID: "tallies", Type: "map[uint256]uint256", Exported: true})
22+
schema.AddState(metamodel.State{ID: "pollConfig", Type: "uint256"})
23+
24+
// Actions
25+
schema.AddAction(metamodel.Action{ID: "createPoll", Guard: "pollConfig == 0", EventID: "PollCreated"})
26+
schema.AddAction(metamodel.Action{ID: "castVote", Guard: "pollConfig == 1 && nullifiers[nullifier] == false", EventID: "VoteCast"})
27+
schema.AddAction(metamodel.Action{ID: "closePoll", Guard: "pollConfig == 1", EventID: "PollClosed"})
28+
29+
// Arcs
30+
// castVote -> tallies (each vote counts as 1)
31+
schema.AddArc(metamodel.Arc{Source: "castVote", Target: "tallies", Keys: []string{"choice"}, Value: "1"})
32+
// castVote -> nullifiers (mark nullifier as used to prevent double-voting)
33+
schema.AddArc(metamodel.Arc{Source: "castVote", Target: "nullifiers", Keys: []string{"nullifier"}, Value: "true"})
34+
// Note: voterRegistry is verified via ZK proof (Merkle inclusion), not via on-chain arc
35+
36+
// Events
37+
schema.AddEvent(metamodel.Event{
38+
ID: "PollCreated",
39+
Signature: "PollCreated(uint256,uint256,uint256)",
40+
Topic: "0x1c9c94bf784c3abe5ad5e8f368e489aad039ae0b4efcf53f28d01c8e1f8e0e4a",
41+
Parameters: []metamodel.EventParameter{
42+
{Name: "epoch", Type: "uint256"},
43+
{Name: "seq", Type: "uint256"},
44+
{Name: "pollId", Type: "uint256", Indexed: true},
45+
},
46+
})
47+
schema.AddEvent(metamodel.Event{
48+
ID: "VoteCast",
49+
Signature: "VoteCast(uint256,uint256,uint256,uint256)",
50+
Topic: "0x3a4d2e8dd6e4076b2f2e9e3b6dbde64e1df44c23e6f5c4e8b19a76c38d4f1b2d",
51+
Parameters: []metamodel.EventParameter{
52+
{Name: "epoch", Type: "uint256"},
53+
{Name: "seq", Type: "uint256"},
54+
{Name: "nullifier", Type: "uint256", Indexed: true},
55+
{Name: "choice", Type: "uint256"},
56+
},
57+
})
58+
schema.AddEvent(metamodel.Event{
59+
ID: "PollClosed",
60+
Signature: "PollClosed(uint256,uint256,uint256)",
61+
Topic: "0x5e2f8a95e5b3c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7a8b9c0d1",
62+
Parameters: []metamodel.EventParameter{
63+
{Name: "epoch", Type: "uint256"},
64+
{Name: "seq", Type: "uint256"},
65+
{Name: "pollId", Type: "uint256", Indexed: true},
66+
},
67+
})
68+
69+
model := arc.FromSchema(schema)
70+
return &Vote{BaseTemplate: BaseTemplate{
71+
schema: schema,
72+
model: model,
73+
metadata: TokenMetadata{Name: name, Symbol: "VOTE", Standard: StandardVote},
74+
standard: StandardVote,
75+
}}
76+
}

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ require (
1212
github.com/pflow-xyz/go-pflow v0.9.0
1313
github.com/piprate/json-gold v0.7.0
1414
github.com/rs/zerolog v1.34.0
15+
golang.org/x/crypto v0.44.0
1516
golang.org/x/sync v0.18.0
1617
)
1718

@@ -36,7 +37,6 @@ require (
3637
github.com/spaolacci/murmur3 v1.1.0 // indirect
3738
github.com/stretchr/testify v1.10.0 // indirect
3839
github.com/x448/float16 v0.8.4 // indirect
39-
golang.org/x/crypto v0.44.0 // indirect
4040
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
4141
golang.org/x/sys v0.39.0 // indirect
4242
gopkg.in/yaml.v3 v3.0.1 // indirect

internal/server/auth.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
package server
2+
3+
import (
4+
"crypto/ecdsa"
5+
"crypto/elliptic"
6+
"encoding/hex"
7+
"errors"
8+
"fmt"
9+
"math/big"
10+
"strings"
11+
12+
"golang.org/x/crypto/sha3"
13+
)
14+
15+
// secp256k1 curve parameters
16+
var secp256k1 = &elliptic.CurveParams{
17+
P: fromHex("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F"),
18+
N: fromHex("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141"),
19+
B: big.NewInt(7),
20+
Gx: fromHex("79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798"),
21+
Gy: fromHex("483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8"),
22+
BitSize: 256,
23+
Name: "secp256k1",
24+
}
25+
26+
func fromHex(s string) *big.Int {
27+
n, _ := new(big.Int).SetString(s, 16)
28+
return n
29+
}
30+
31+
// keccak256 computes the Keccak-256 hash.
32+
func keccak256(data []byte) []byte {
33+
h := sha3.NewLegacyKeccak256()
34+
h.Write(data)
35+
return h.Sum(nil)
36+
}
37+
38+
// RecoverAddress recovers an Ethereum address from an EIP-191 personal_sign signature.
39+
// message: the original message text (without prefix)
40+
// signature: hex-encoded 65-byte signature (r || s || v)
41+
func RecoverAddress(message string, signature string) (string, error) {
42+
sig, err := hex.DecodeString(strings.TrimPrefix(signature, "0x"))
43+
if err != nil {
44+
return "", fmt.Errorf("invalid signature hex: %w", err)
45+
}
46+
if len(sig) != 65 {
47+
return "", fmt.Errorf("signature must be 65 bytes, got %d", len(sig))
48+
}
49+
50+
// EIP-191 personal_sign prefix
51+
prefix := fmt.Sprintf("\x19Ethereum Signed Message:\n%d", len(message))
52+
hash := keccak256(append([]byte(prefix), []byte(message)...))
53+
54+
// Extract r, s, v from signature
55+
r := new(big.Int).SetBytes(sig[:32])
56+
s := new(big.Int).SetBytes(sig[32:64])
57+
v := sig[64]
58+
59+
// Normalize v (27/28 -> 0/1)
60+
if v >= 27 {
61+
v -= 27
62+
}
63+
if v > 1 {
64+
return "", errors.New("invalid signature recovery id")
65+
}
66+
67+
// Recover public key
68+
pubKey, err := recoverPubKey(hash, r, s, v)
69+
if err != nil {
70+
return "", fmt.Errorf("public key recovery failed: %w", err)
71+
}
72+
73+
// Derive Ethereum address from public key
74+
pubBytes := elliptic.Marshal(secp256k1, pubKey.X, pubKey.Y)
75+
addr := keccak256(pubBytes[1:]) // skip 0x04 prefix
76+
return "0x" + hex.EncodeToString(addr[12:]), nil
77+
}
78+
79+
// recoverPubKey recovers the ECDSA public key from a signature.
80+
func recoverPubKey(hash []byte, r, s *big.Int, v byte) (*ecdsa.PublicKey, error) {
81+
// Calculate R point on the curve
82+
rx := new(big.Int).Set(r)
83+
if v == 1 {
84+
rx.Add(rx, secp256k1.N)
85+
}
86+
87+
// Calculate y from x on secp256k1: y^2 = x^3 + 7
88+
ry := decompressPoint(rx, v%2 == 1)
89+
if ry == nil {
90+
return nil, errors.New("invalid signature: point not on curve")
91+
}
92+
93+
// R = (rx, ry)
94+
// e = hash as big.Int
95+
e := new(big.Int).SetBytes(hash)
96+
97+
// Recover public key: Q = r^-1 * (s*R - e*G)
98+
rInv := new(big.Int).ModInverse(r, secp256k1.N)
99+
if rInv == nil {
100+
return nil, errors.New("invalid signature: r has no inverse")
101+
}
102+
103+
// s*R
104+
sRx, sRy := secp256k1.ScalarMult(rx, ry, s.Bytes())
105+
106+
// e*G
107+
eGx, eGy := secp256k1.ScalarBaseMult(e.Bytes())
108+
109+
// s*R - e*G
110+
eGy.Neg(eGy)
111+
eGy.Mod(eGy, secp256k1.P)
112+
sumX, sumY := secp256k1.Add(sRx, sRy, eGx, eGy)
113+
114+
// Q = r^-1 * (s*R - e*G)
115+
qx, qy := secp256k1.ScalarMult(sumX, sumY, rInv.Bytes())
116+
117+
return &ecdsa.PublicKey{Curve: secp256k1, X: qx, Y: qy}, nil
118+
}
119+
120+
// decompressPoint finds the y coordinate for a given x on secp256k1.
121+
func decompressPoint(x *big.Int, odd bool) *big.Int {
122+
// y^2 = x^3 + 7 (mod p)
123+
x3 := new(big.Int).Mul(x, x)
124+
x3.Mul(x3, x)
125+
x3.Mod(x3, secp256k1.P)
126+
127+
y2 := new(big.Int).Add(x3, big.NewInt(7))
128+
y2.Mod(y2, secp256k1.P)
129+
130+
// sqrt via Tonelli-Shanks (p ≡ 3 mod 4 for secp256k1)
131+
exp := new(big.Int).Add(secp256k1.P, big.NewInt(1))
132+
exp.Rsh(exp, 2)
133+
y := new(big.Int).Exp(y2, exp, secp256k1.P)
134+
135+
// Verify
136+
check := new(big.Int).Mul(y, y)
137+
check.Mod(check, secp256k1.P)
138+
if check.Cmp(y2) != 0 {
139+
return nil
140+
}
141+
142+
// Adjust parity
143+
if odd != (y.Bit(0) == 1) {
144+
y.Sub(secp256k1.P, y)
145+
}
146+
147+
return y
148+
}

0 commit comments

Comments
 (0)