Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}