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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

qvote.gno

9.01 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/nym-moultest123/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}