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