qvote.gno
9.00 Kb · 320 lines
1// Package qvote is a quadratic-voting poll board, a Gno take on the classic
2// Solidity "Ballot" voting contract with one twist borrowed from mechanism
3// design instead of one-address-one-vote: every voter gets a fixed voice-
4// credit budget per poll, and piling votes onto a single option costs the
5// square of how many votes they stack there. Buying your 1st vote on an
6// option costs 1 credit, the 2nd costs 3 more (4 total), the 3rd costs 5
7// more (9 total) -- so spreading conviction across options is cheap, but
8// dominating one option gets expensive fast. Votes can also be pulled back
9// for a matching credit refund.
10package qvote
11
12import (
13 "strconv"
14 "strings"
15
16 "chain"
17 "chain/runtime"
18
19 "gno.land/p/moul/kit/ui/v0"
20 "gno.land/p/nt/avl/v0"
21)
22
23// InitialCredits is the fixed voice-credit budget every address gets to
24// spend on each poll (shared across all of that poll's options).
25const InitialCredits int64 = 100
26
27const (
28 minOptions = 2
29 maxOptions = 8
30)
31
32type poll struct {
33 ID string
34 Creator address
35 Question string
36 Options []string
37 Tally []int64
38 CreatedHeight int64
39 Closed bool
40}
41
42// voterState is one address's standing inside one poll: how many credits
43// they've spent so far and how many votes that bought them on each option.
44type voterState struct {
45 CreditsUsed int64
46 Votes []int64
47}
48
49var (
50 polls avl.Tree // poll ID -> *poll
51 voters avl.Tree // "<pollID>:<addr>" -> *voterState
52 nextID int
53)
54
55func voterKey(pollID string, addr address) string {
56 return pollID + ":" + addr.String()
57}
58
59func getPoll(pollID string) *poll {
60 p, ok := polls.Get(pollID).(*poll)
61 if !ok {
62 panic("no such poll: " + pollID)
63 }
64 return p
65}
66
67func getOrCreateVoter(pollID string, addr address, numOptions int) *voterState {
68 key := voterKey(pollID, addr)
69 if v, ok := voters.Get(key).(*voterState); ok {
70 return v
71 }
72 vs := &voterState{Votes: make([]int64, numOptions)}
73 voters.Set(key, vs)
74 return vs
75}
76
77// square is the quadratic-voting cost curve: N votes on one option cost
78// N*N credits in total.
79func square(n int64) int64 {
80 return n * n
81}
82
83// CreatePoll opens a new poll with the given question and comma-separated
84// options (at least 2, at most 8), returning its ID.
85func CreatePoll(cur realm, question string, optionsCSV string) string {
86 creator := cur.Previous().Address()
87
88 question = strings.TrimSpace(question)
89 if question == "" {
90 panic("question must not be empty")
91 }
92
93 var options []string
94 for _, raw := range strings.Split(optionsCSV, ",") {
95 opt := strings.TrimSpace(raw)
96 if opt == "" {
97 continue
98 }
99 options = append(options, opt)
100 }
101 if len(options) < minOptions {
102 panic("need at least " + strconv.Itoa(minOptions) + " non-empty options")
103 }
104 if len(options) > maxOptions {
105 panic("at most " + strconv.Itoa(maxOptions) + " options are allowed")
106 }
107
108 nextID++
109 id := strconv.Itoa(nextID)
110 p := &poll{
111 ID: id,
112 Creator: creator,
113 Question: question,
114 Options: options,
115 Tally: make([]int64, len(options)),
116 CreatedHeight: runtime.ChainHeight(),
117 }
118 polls.Set(id, p)
119
120 chain.Emit("PollCreated", "id", id, "creator", creator.String(), "question", question)
121
122 return "poll #" + id + " created with " + strconv.Itoa(len(options)) + " options"
123}
124
125// Vote adjusts the caller's votes on one option of a poll by delta (positive
126// to buy more, negative to sell some back for a credit refund). The credit
127// cost of holding N votes on an option is N*N, taken from the caller's fixed
128// per-poll budget of InitialCredits.
129func Vote(cur realm, pollID string, optionIdx int, delta int64) string {
130 caller := cur.Previous().Address()
131
132 p := getPoll(pollID)
133 if p.Closed {
134 panic("poll #" + pollID + " is closed")
135 }
136 if optionIdx < 0 || optionIdx >= len(p.Options) {
137 panic("invalid option index")
138 }
139 if delta == 0 {
140 panic("delta must be non-zero")
141 }
142
143 vs := getOrCreateVoter(pollID, caller, len(p.Options))
144 current := vs.Votes[optionIdx]
145 updated := current + delta
146 if updated < 0 {
147 panic("cannot remove more votes than you hold on this option")
148 }
149
150 cost := square(updated) - square(current)
151 newCreditsUsed := vs.CreditsUsed + cost
152 if newCreditsUsed > InitialCredits {
153 panic("exceeds your voice-credit budget of " + strconv.FormatInt(InitialCredits, 10) +
154 " for this poll (would need " + strconv.FormatInt(newCreditsUsed, 10) + ")")
155 }
156
157 vs.Votes[optionIdx] = updated
158 vs.CreditsUsed = newCreditsUsed
159 p.Tally[optionIdx] += delta
160
161 chain.Emit("VoteCast",
162 "pollID", pollID,
163 "voter", caller.String(),
164 "option", p.Options[optionIdx],
165 "votes", strconv.FormatInt(updated, 10),
166 )
167
168 verb := "bought"
169 n := delta
170 if delta < 0 {
171 verb = "sold back"
172 n = -delta
173 }
174 return "you " + verb + " " + strconv.FormatInt(n, 10) + " vote(s) on \"" + p.Options[optionIdx] +
175 "\" -- now holding " + strconv.FormatInt(updated, 10) + " (credits used: " +
176 strconv.FormatInt(vs.CreditsUsed, 10) + "/" + strconv.FormatInt(InitialCredits, 10) + ")"
177}
178
179// ClosePoll ends voting on a poll. Only its creator may close it.
180func ClosePoll(cur realm, pollID string) string {
181 caller := cur.Previous().Address()
182
183 p := getPoll(pollID)
184 if caller != p.Creator {
185 panic("only the poll creator can close it")
186 }
187 if p.Closed {
188 panic("poll #" + pollID + " is already closed")
189 }
190 p.Closed = true
191
192 chain.Emit("PollClosed", "id", pollID)
193
194 return "poll #" + pollID + " closed"
195}
196
197func leadingOption(p *poll) (string, int64) {
198 best := -1
199 var bestVotes int64 = -1
200 for i, v := range p.Tally {
201 if v > bestVotes {
202 bestVotes = v
203 best = i
204 }
205 }
206 if best < 0 {
207 return "", 0
208 }
209 return p.Options[best], bestVotes
210}
211
212func renderHome() string {
213 var b strings.Builder
214 b.WriteString("# Quadratic Voting\n\n")
215 b.WriteString("Create a poll, then spend voice credits on the options you care about -- " +
216 "the Nth vote you stack on one option costs N^2 credits out of a fixed budget of " +
217 strconv.FormatInt(InitialCredits, 10) + " per poll, so spreading support across " +
218 "options is cheap but dominating one gets steep fast.\n\n")
219
220 b.WriteString("## Polls\n\n")
221 count := 0
222 polls.Iterate("", "", func(key string, value interface{}) bool {
223 count++
224 p := value.(*poll)
225 status := "open"
226 if p.Closed {
227 status = "closed"
228 }
229 lead, votes := leadingOption(p)
230 line := "- #" + p.ID + " (" + status + "): " + ui.Inline(p.Question)
231 if lead != "" {
232 line += " -- leading: \"" + ui.Inline(lead) + "\" (" + strconv.FormatInt(votes, 10) + ")"
233 }
234 b.WriteString(line + "\n")
235 return false
236 })
237 if count == 0 {
238 b.WriteString("_no polls yet -- call `CreatePoll(question, \"opt1,opt2,...\")` to start one._\n")
239 }
240
241 b.WriteString("\n## How to use\n\n")
242 b.WriteString("1. `CreatePoll(\"Best pizza topping?\", \"pineapple,mushroom,pepperoni\")` -- returns a poll ID.\n")
243 b.WriteString("2. `Vote(pollID, optionIdx, delta)` -- e.g. `Vote(\"1\", 0, 3)` buys 3 votes on option 0 " +
244 "(costs 9 credits); a negative delta sells votes back for a refund.\n")
245 b.WriteString("3. `ClosePoll(pollID)` -- the creator ends voting.\n\n")
246 b.WriteString("View a poll at this realm's path plus its ID (e.g. `.../qvote:1`), " +
247 "or one voter's standing in it with `.../qvote:1/g1youraddress...`.\n")
248
249 return b.String()
250}
251
252func renderPoll(id string) string {
253 p, ok := polls.Get(id).(*poll)
254 if !ok {
255 return "# Poll #" + ui.Inline(id) + "\n\nNo such poll.\n"
256 }
257
258 var b strings.Builder
259 b.WriteString("# Poll #" + p.ID + ": " + ui.Inline(p.Question) + "\n\n")
260 status := "open"
261 if p.Closed {
262 status = "closed"
263 }
264 b.WriteString("- Status: " + status + "\n")
265 b.WriteString("- Creator: `" + p.Creator.String() + "`\n\n")
266
267 b.WriteString("## Results\n\n")
268 var total int64
269 for _, v := range p.Tally {
270 total += v
271 }
272 for i, opt := range p.Options {
273 b.WriteString(strconv.Itoa(i) + ". " + ui.Inline(opt) + " -- " +
274 strconv.FormatInt(p.Tally[i], 10) + " vote(s)\n")
275 }
276 b.WriteString("\nTotal votes cast: " + strconv.FormatInt(total, 10) + "\n")
277
278 return b.String()
279}
280
281func renderVoter(pollID, rawAddr string) string {
282 safeAddr := ui.Inline(strings.TrimSpace(rawAddr))
283 p, ok := polls.Get(pollID).(*poll)
284 if !ok {
285 return "# Poll #" + ui.Inline(pollID) + "\n\nNo such poll.\n"
286 }
287
288 var b strings.Builder
289 b.WriteString("# " + safeAddr + " in poll #" + p.ID + "\n\n")
290
291 state, ok := voters.Get(voterKey(pollID, address(strings.TrimSpace(rawAddr)))).(*voterState)
292 if !ok {
293 b.WriteString("No votes cast yet.\n")
294 return b.String()
295 }
296
297 b.WriteString("- Credits used: " + strconv.FormatInt(state.CreditsUsed, 10) + "/" +
298 strconv.FormatInt(InitialCredits, 10) + "\n\n")
299 for i, opt := range p.Options {
300 if state.Votes[i] == 0 {
301 continue
302 }
303 b.WriteString("- " + ui.Inline(opt) + ": " + strconv.FormatInt(state.Votes[i], 10) + " vote(s)\n")
304 }
305
306 return b.String()
307}
308
309// Render shows the poll board at "", one poll's results at "<pollID>", or
310// one voter's standing in a poll at "<pollID>/<addr>".
311func Render(path string) string {
312 path = strings.TrimPrefix(strings.TrimSpace(path), "/")
313 if path == "" {
314 return renderHome()
315 }
316 if idx := strings.IndexByte(path, '/'); idx >= 0 {
317 return renderVoter(path[:idx], path[idx+1:])
318 }
319 return renderPoll(path)
320}