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