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.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}