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