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

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}