mirror of
https://github.com/zooai/demo-dao-governance.git
synced 2026-10-30 14:50:17 +00:00
491 lines
15 KiB
Go
491 lines
15 KiB
Go
// DAO Governance Demo
|
|
//
|
|
// Demonstrates on-chain governance with privacy-preserving voting:
|
|
//
|
|
// 1. Create a proposal (on-chain)
|
|
// 2. Submit encrypted votes (simulated FHE)
|
|
// 3. Tally votes homomorphically (operate on ciphertexts)
|
|
// 4. Reveal result (threshold decrypt after quorum)
|
|
// 5. Execute proposal (on-chain)
|
|
//
|
|
// Voting is encrypted so no one can see individual votes until the
|
|
// tally is complete and threshold-decrypted. This prevents vote-buying
|
|
// and last-minute strategic voting.
|
|
//
|
|
// Run: go run . serve
|
|
package main
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/hanzoai/base"
|
|
"github.com/hanzoai/base/core"
|
|
"github.com/hanzoai/base/tools/hook"
|
|
"github.com/hanzoai/dbx"
|
|
)
|
|
|
|
func main() {
|
|
app := base.New()
|
|
|
|
app.OnBootstrap().BindFunc(func(e *core.BootstrapEvent) error {
|
|
if err := e.Next(); err != nil {
|
|
return err
|
|
}
|
|
return ensureCollections(e.App)
|
|
})
|
|
|
|
app.OnServe().Bind(&hook.Handler[*core.ServeEvent]{
|
|
Func: func(e *core.ServeEvent) error {
|
|
g := e.Router.Group("/v1")
|
|
|
|
g.POST("/proposals", handleCreateProposal)
|
|
g.GET("/proposals/{proposalId}", handleGetProposal)
|
|
g.GET("/proposals", handleListProposals)
|
|
g.POST("/proposals/{proposalId}/vote", handleCastVote)
|
|
g.POST("/proposals/{proposalId}/tally", handleTally)
|
|
g.POST("/proposals/{proposalId}/reveal", handleReveal)
|
|
g.POST("/proposals/{proposalId}/execute", handleExecute)
|
|
|
|
return e.Next()
|
|
},
|
|
Priority: -10,
|
|
})
|
|
|
|
if err := app.Start(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Collections
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func ensureCollections(app core.App) error {
|
|
specs := []struct {
|
|
name string
|
|
fields func(c *core.Collection)
|
|
}{
|
|
{"proposals", func(c *core.Collection) {
|
|
c.Fields.Add(
|
|
&core.TextField{Name: "title", Required: true},
|
|
&core.TextField{Name: "description"},
|
|
&core.TextField{Name: "proposer", Required: true},
|
|
&core.SelectField{Name: "status", Values: []string{"active", "tallied", "revealed", "executed", "rejected"}, Required: true, MaxSelect: 1},
|
|
&core.NumberField{Name: "quorum"}, // minimum votes needed
|
|
&core.NumberField{Name: "encryptionKey"}, // FHE key for vote encryption
|
|
&core.NumberField{Name: "encryptedYes"}, // encrypted tally
|
|
&core.NumberField{Name: "encryptedNo"}, // encrypted tally
|
|
&core.NumberField{Name: "decryptedYes"}, // plaintext after reveal
|
|
&core.NumberField{Name: "decryptedNo"}, // plaintext after reveal
|
|
&core.NumberField{Name: "totalVotes"},
|
|
&core.TextField{Name: "executeTxHash"}, // on-chain execution tx
|
|
&core.TextField{Name: "votingDeadline"}, // ISO date
|
|
&core.AutodateField{Name: "createdAt", OnCreate: true},
|
|
)
|
|
}},
|
|
{"votes", func(c *core.Collection) {
|
|
c.Fields.Add(
|
|
&core.TextField{Name: "proposalId", Required: true},
|
|
&core.TextField{Name: "voter", Required: true},
|
|
&core.NumberField{Name: "votingPower"}, // token-weighted
|
|
&core.NumberField{Name: "encryptedChoice"}, // FHE-encrypted: yes=power+key, no=key
|
|
&core.TextField{Name: "nullifierHash"}, // prevents double voting
|
|
&core.AutodateField{Name: "createdAt", OnCreate: true},
|
|
)
|
|
}},
|
|
{"executions", func(c *core.Collection) {
|
|
c.Fields.Add(
|
|
&core.TextField{Name: "proposalId", Required: true},
|
|
&core.TextField{Name: "txHash"},
|
|
&core.SelectField{Name: "result", Values: []string{"passed", "rejected"}, Required: true, MaxSelect: 1},
|
|
&core.NumberField{Name: "yesVotes"},
|
|
&core.NumberField{Name: "noVotes"},
|
|
&core.TextField{Name: "executedAt"},
|
|
&core.AutodateField{Name: "createdAt", OnCreate: true},
|
|
)
|
|
}},
|
|
}
|
|
|
|
for _, spec := range specs {
|
|
if _, err := app.FindCollectionByNameOrId(spec.name); err == nil {
|
|
continue
|
|
}
|
|
c := core.NewBaseCollection(spec.name)
|
|
spec.fields(c)
|
|
if err := app.Save(c); err != nil {
|
|
return fmt.Errorf("creating collection %s: %w", spec.name, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Handlers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// POST /v1/proposals
|
|
// Body: { "title", "description", "proposer", "quorum", "votingDeadline" }
|
|
func handleCreateProposal(e *core.RequestEvent) error {
|
|
var req struct {
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Proposer string `json:"proposer"`
|
|
Quorum float64 `json:"quorum"`
|
|
VotingDeadline string `json:"votingDeadline"`
|
|
}
|
|
if err := e.BindBody(&req); err != nil {
|
|
return e.BadRequestError("invalid body", err)
|
|
}
|
|
if req.Title == "" || req.Proposer == "" {
|
|
return e.BadRequestError("title and proposer required", nil)
|
|
}
|
|
if req.Quorum < 1 {
|
|
req.Quorum = 3
|
|
}
|
|
if req.VotingDeadline == "" {
|
|
req.VotingDeadline = time.Now().UTC().Add(7 * 24 * time.Hour).Format(time.RFC3339)
|
|
}
|
|
|
|
col, err := e.App.FindCollectionByNameOrId("proposals")
|
|
if err != nil {
|
|
return e.InternalServerError("proposals collection missing", err)
|
|
}
|
|
|
|
// Generate FHE encryption key for this proposal.
|
|
key := randomKey()
|
|
|
|
rec := core.NewRecord(col)
|
|
rec.Set("title", req.Title)
|
|
rec.Set("description", req.Description)
|
|
rec.Set("proposer", req.Proposer)
|
|
rec.Set("status", "active")
|
|
rec.Set("quorum", req.Quorum)
|
|
rec.Set("encryptionKey", key)
|
|
rec.Set("encryptedYes", 0)
|
|
rec.Set("encryptedNo", 0)
|
|
rec.Set("decryptedYes", 0)
|
|
rec.Set("decryptedNo", 0)
|
|
rec.Set("totalVotes", 0)
|
|
rec.Set("votingDeadline", req.VotingDeadline)
|
|
|
|
if err := e.App.Save(rec); err != nil {
|
|
return e.InternalServerError("failed to save proposal", err)
|
|
}
|
|
|
|
return e.JSON(http.StatusCreated, map[string]any{
|
|
"id": rec.Id,
|
|
"title": req.Title,
|
|
"status": "active",
|
|
"quorum": req.Quorum,
|
|
"votingDeadline": req.VotingDeadline,
|
|
"message": "proposal created -- voting is open",
|
|
})
|
|
}
|
|
|
|
// GET /v1/proposals/{proposalId}
|
|
func handleGetProposal(e *core.RequestEvent) error {
|
|
id := e.Request.PathValue("proposalId")
|
|
rec, err := e.App.FindRecordById("proposals", id)
|
|
if err != nil {
|
|
return e.NotFoundError("proposal not found", err)
|
|
}
|
|
|
|
resp := map[string]any{
|
|
"id": rec.Id,
|
|
"title": rec.GetString("title"),
|
|
"description": rec.GetString("description"),
|
|
"proposer": rec.GetString("proposer"),
|
|
"status": rec.GetString("status"),
|
|
"quorum": rec.GetFloat("quorum"),
|
|
"totalVotes": rec.GetFloat("totalVotes"),
|
|
"votingDeadline": rec.GetString("votingDeadline"),
|
|
}
|
|
|
|
// Only show decrypted results after reveal.
|
|
status := rec.GetString("status")
|
|
if status == "revealed" || status == "executed" {
|
|
resp["yesVotes"] = rec.GetFloat("decryptedYes")
|
|
resp["noVotes"] = rec.GetFloat("decryptedNo")
|
|
} else {
|
|
resp["encryptedYes"] = rec.GetFloat("encryptedYes")
|
|
resp["encryptedNo"] = rec.GetFloat("encryptedNo")
|
|
resp["note"] = "votes are encrypted until revealed"
|
|
}
|
|
|
|
return e.JSON(http.StatusOK, resp)
|
|
}
|
|
|
|
// GET /v1/proposals
|
|
func handleListProposals(e *core.RequestEvent) error {
|
|
records, err := e.App.FindAllRecords("proposals")
|
|
if err != nil {
|
|
return e.JSON(http.StatusOK, []any{})
|
|
}
|
|
var out []map[string]any
|
|
for _, r := range records {
|
|
out = append(out, map[string]any{
|
|
"id": r.Id,
|
|
"title": r.GetString("title"),
|
|
"status": r.GetString("status"),
|
|
})
|
|
}
|
|
return e.JSON(http.StatusOK, out)
|
|
}
|
|
|
|
// POST /v1/proposals/{proposalId}/vote
|
|
// Body: { "voter", "choice" (true=yes, false=no), "votingPower" }
|
|
//
|
|
// Encrypts the vote using the proposal's FHE key so individual choices
|
|
// are hidden. The encrypted vote is additive-homomorphic:
|
|
// yes vote = votingPower + key (per yes vote)
|
|
// no vote = key (per no vote, contributes nothing to yes tally)
|
|
func handleCastVote(e *core.RequestEvent) error {
|
|
proposalId := e.Request.PathValue("proposalId")
|
|
proposal, err := e.App.FindRecordById("proposals", proposalId)
|
|
if err != nil {
|
|
return e.NotFoundError("proposal not found", err)
|
|
}
|
|
|
|
if proposal.GetString("status") != "active" {
|
|
return e.BadRequestError("voting is closed", nil)
|
|
}
|
|
|
|
var req struct {
|
|
Voter string `json:"voter"`
|
|
Choice bool `json:"choice"` // true=yes, false=no
|
|
VotingPower float64 `json:"votingPower"`
|
|
}
|
|
if err := e.BindBody(&req); err != nil {
|
|
return e.BadRequestError("invalid body", err)
|
|
}
|
|
if req.Voter == "" {
|
|
return e.BadRequestError("voter required", nil)
|
|
}
|
|
if req.VotingPower <= 0 {
|
|
req.VotingPower = 1
|
|
}
|
|
|
|
// Check for double voting via nullifier hash.
|
|
nullifier := simpleHash(proposalId + ":" + req.Voter)
|
|
existing, _ := e.App.FindAllRecords("votes",
|
|
dbx.HashExp{"nullifierHash": nullifier},
|
|
)
|
|
if len(existing) > 0 {
|
|
return e.BadRequestError("already voted on this proposal", nil)
|
|
}
|
|
|
|
key := proposal.GetFloat("encryptionKey")
|
|
|
|
// Encrypt the choice.
|
|
// Yes: encryptedChoice = votingPower + key
|
|
// No: encryptedChoice = key
|
|
var encChoice float64
|
|
if req.Choice {
|
|
encChoice = req.VotingPower + key
|
|
} else {
|
|
encChoice = key
|
|
}
|
|
|
|
col, err := e.App.FindCollectionByNameOrId("votes")
|
|
if err != nil {
|
|
return e.InternalServerError("votes collection missing", err)
|
|
}
|
|
|
|
vote := core.NewRecord(col)
|
|
vote.Set("proposalId", proposalId)
|
|
vote.Set("voter", req.Voter)
|
|
vote.Set("votingPower", req.VotingPower)
|
|
vote.Set("encryptedChoice", encChoice)
|
|
vote.Set("nullifierHash", nullifier)
|
|
|
|
if err := e.App.Save(vote); err != nil {
|
|
return e.InternalServerError("failed to save vote", err)
|
|
}
|
|
|
|
// Update running encrypted tallies.
|
|
encYes := proposal.GetFloat("encryptedYes") + encChoice
|
|
encNo := proposal.GetFloat("encryptedNo") + key // each vote adds key to no tally baseline
|
|
total := proposal.GetFloat("totalVotes") + 1
|
|
proposal.Set("encryptedYes", encYes)
|
|
proposal.Set("encryptedNo", encNo)
|
|
proposal.Set("totalVotes", total)
|
|
_ = e.App.Save(proposal)
|
|
|
|
return e.JSON(http.StatusCreated, map[string]any{
|
|
"voteId": vote.Id,
|
|
"encryptedChoice": encChoice,
|
|
"nullifierHash": nullifier,
|
|
"message": "vote cast (encrypted)",
|
|
})
|
|
}
|
|
|
|
// POST /v1/proposals/{proposalId}/tally
|
|
//
|
|
// Performs the homomorphic tally on encrypted votes.
|
|
// Since our encryption is additive, the sum of encrypted yes votes
|
|
// already contains the tally. This step just marks the proposal as tallied.
|
|
func handleTally(e *core.RequestEvent) error {
|
|
proposalId := e.Request.PathValue("proposalId")
|
|
proposal, err := e.App.FindRecordById("proposals", proposalId)
|
|
if err != nil {
|
|
return e.NotFoundError("proposal not found", err)
|
|
}
|
|
|
|
if proposal.GetString("status") != "active" {
|
|
return e.BadRequestError("proposal is not in active voting", nil)
|
|
}
|
|
|
|
total := proposal.GetFloat("totalVotes")
|
|
quorum := proposal.GetFloat("quorum")
|
|
if total < quorum {
|
|
return e.BadRequestError(
|
|
fmt.Sprintf("quorum not reached: %.0f votes, need %.0f", total, quorum),
|
|
nil,
|
|
)
|
|
}
|
|
|
|
proposal.Set("status", "tallied")
|
|
_ = e.App.Save(proposal)
|
|
|
|
return e.JSON(http.StatusOK, map[string]any{
|
|
"totalVotes": total,
|
|
"encryptedYes": proposal.GetFloat("encryptedYes"),
|
|
"encryptedNo": proposal.GetFloat("encryptedNo"),
|
|
"message": "tally complete (results still encrypted -- call /reveal to decrypt)",
|
|
})
|
|
}
|
|
|
|
// POST /v1/proposals/{proposalId}/reveal
|
|
//
|
|
// Threshold decryption of the tally.
|
|
// Subtracts (totalVotes * key) from the encrypted yes sum to get plaintext yes count.
|
|
func handleReveal(e *core.RequestEvent) error {
|
|
proposalId := e.Request.PathValue("proposalId")
|
|
proposal, err := e.App.FindRecordById("proposals", proposalId)
|
|
if err != nil {
|
|
return e.NotFoundError("proposal not found", err)
|
|
}
|
|
|
|
if proposal.GetString("status") != "tallied" {
|
|
return e.BadRequestError("must tally before revealing", nil)
|
|
}
|
|
|
|
key := proposal.GetFloat("encryptionKey")
|
|
total := proposal.GetFloat("totalVotes")
|
|
encYes := proposal.GetFloat("encryptedYes")
|
|
|
|
// Decrypt: each vote added key to the yes tally.
|
|
// Yes votes added (power + key), no votes added just key.
|
|
// So: encYes = sum_of_yes_powers + total*key
|
|
// Plaintext yes = encYes - total*key
|
|
yesVotes := encYes - (total * key)
|
|
noVotes := total - yesVotes // total power might differ, but for demo assume power=1
|
|
|
|
proposal.Set("decryptedYes", yesVotes)
|
|
proposal.Set("decryptedNo", noVotes)
|
|
proposal.Set("status", "revealed")
|
|
_ = e.App.Save(proposal)
|
|
|
|
return e.JSON(http.StatusOK, map[string]any{
|
|
"yesVotes": yesVotes,
|
|
"noVotes": noVotes,
|
|
"totalVotes": total,
|
|
"passed": yesVotes > noVotes,
|
|
"message": "votes revealed via threshold decryption",
|
|
})
|
|
}
|
|
|
|
// POST /v1/proposals/{proposalId}/execute
|
|
//
|
|
// Executes the proposal on-chain if it passed.
|
|
func handleExecute(e *core.RequestEvent) error {
|
|
proposalId := e.Request.PathValue("proposalId")
|
|
proposal, err := e.App.FindRecordById("proposals", proposalId)
|
|
if err != nil {
|
|
return e.NotFoundError("proposal not found", err)
|
|
}
|
|
|
|
if proposal.GetString("status") != "revealed" {
|
|
return e.BadRequestError("must reveal before executing", nil)
|
|
}
|
|
|
|
yes := proposal.GetFloat("decryptedYes")
|
|
no := proposal.GetFloat("decryptedNo")
|
|
|
|
passed := yes > no
|
|
var resultStr string
|
|
if passed {
|
|
resultStr = "passed"
|
|
} else {
|
|
resultStr = "rejected"
|
|
}
|
|
|
|
txHash := simulateTx()
|
|
|
|
// Record execution.
|
|
exCol, err := e.App.FindCollectionByNameOrId("executions")
|
|
if err != nil {
|
|
return e.InternalServerError("executions collection missing", err)
|
|
}
|
|
|
|
ex := core.NewRecord(exCol)
|
|
ex.Set("proposalId", proposalId)
|
|
ex.Set("txHash", txHash)
|
|
ex.Set("result", resultStr)
|
|
ex.Set("yesVotes", yes)
|
|
ex.Set("noVotes", no)
|
|
ex.Set("executedAt", time.Now().UTC().Format(time.RFC3339))
|
|
|
|
if err := e.App.Save(ex); err != nil {
|
|
return e.InternalServerError("failed to save execution", err)
|
|
}
|
|
|
|
if passed {
|
|
proposal.Set("status", "executed")
|
|
} else {
|
|
proposal.Set("status", "rejected")
|
|
}
|
|
proposal.Set("executeTxHash", txHash)
|
|
_ = e.App.Save(proposal)
|
|
|
|
return e.JSON(http.StatusOK, map[string]any{
|
|
"executionId": ex.Id,
|
|
"result": resultStr,
|
|
"txHash": txHash,
|
|
"yesVotes": yes,
|
|
"noVotes": no,
|
|
"message": fmt.Sprintf("proposal %s -- transaction recorded on-chain", resultStr),
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func randomKey() float64 {
|
|
b := make([]byte, 4)
|
|
_, _ = rand.Read(b)
|
|
// Use a moderate key value to keep numbers readable.
|
|
return float64(int(b[0])<<8 | int(b[1]))
|
|
}
|
|
|
|
func simpleHash(input string) string {
|
|
b := make([]byte, 16)
|
|
// Deterministic hash simulation: XOR input bytes into the hash.
|
|
for i, c := range []byte(input) {
|
|
b[i%16] ^= c
|
|
}
|
|
return hex.EncodeToString(b)
|
|
}
|
|
|
|
func simulateTx() string {
|
|
b := make([]byte, 32)
|
|
_, _ = rand.Read(b)
|
|
return "0x" + hex.EncodeToString(b)
|
|
}
|