1// Package qvote is a quadratic-voting poll board, a Gno take on the classic2// Solidity "Ballot" voting contract with one twist borrowed from mechanism3// 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 the5// square of how many votes they stack there. Buying your 1st vote on an6// option costs 1 credit, the 2nd costs 3 more (4 total), the 3rd costs 57// more (9 total) -- so spreading conviction across options is cheap, but8// dominating one option gets expensive fast. Votes can also be pulled back9// for a matching credit refund.10package qvote1112import (13 "strconv"14 "strings"1516 "chain"17 "chain/runtime"1819 "gno.land/p/nt/avl/v0"20)2122// InitialCredits is the fixed voice-credit budget every address gets to23// spend on each poll (shared across all of that poll's options).24const InitialCredits int64 = 1002526const (27 minOptions = 228 maxOptions = 829)3031type poll struct {32 ID string33 Creator address34 Question string35 Options []string36 Tally []int6437 CreatedHeight int6438 Closed bool39}4041// voterState is one address's standing inside one poll: how many credits42// they've spent so far and how many votes that bought them on each option.43type voterState struct {44 CreditsUsed int6445 Votes []int6446}4748var (49 polls avl.Tree // poll ID -> *poll50 voters avl.Tree // "<pollID>:<addr>" -> *voterState51 nextID int52)5354func voterKey(pollID string, addr address) string {55 return pollID + ":" + addr.String()56}5758func getPoll(pollID string) *poll {59 p, ok := polls.Get(pollID).(*poll)60 if !ok {61 panic("no such poll: " + pollID)62 }63 return p64}6566func getOrCreateVoter(pollID string, addr address, numOptions int) *voterState {67 key := voterKey(pollID, addr)68 if v, ok := voters.Get(key).(*voterState); ok {69 return v70 }71 vs := &voterState{Votes: make([]int64, numOptions)}72 voters.Set(key, vs)73 return vs74}7576// square is the quadratic-voting cost curve: N votes on one option cost77// N*N credits in total.78func square(n int64) int64 {79 return n * n80}8182// CreatePoll opens a new poll with the given question and comma-separated83// options (at least 2, at most 8), returning its ID.84func CreatePoll(cur realm, question string, optionsCSV string) string {85 creator := cur.Previous().Address()8687 question = strings.TrimSpace(question)88 if question == "" {89 panic("question must not be empty")90 }9192 var options []string93 for _, raw := range strings.Split(optionsCSV, ",") {94 opt := strings.TrimSpace(raw)95 if opt == "" {96 continue97 }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 }106107 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)118119 chain.Emit("PollCreated", "id", id, "creator", creator.String(), "question", question)120121 return "poll #" + id + " created with " + strconv.Itoa(len(options)) + " options"122}123124// Vote adjusts the caller's votes on one option of a poll by delta (positive125// to buy more, negative to sell some back for a credit refund). The credit126// cost of holding N votes on an option is N*N, taken from the caller's fixed127// per-poll budget of InitialCredits.128func Vote(cur realm, pollID string, optionIdx int, delta int64) string {129 caller := cur.Previous().Address()130131 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 }141142 vs := getOrCreateVoter(pollID, caller, len(p.Options))143 current := vs.Votes[optionIdx]144 updated := current + delta145 if updated < 0 {146 panic("cannot remove more votes than you hold on this option")147 }148149 cost := square(updated) - square(current)150 newCreditsUsed := vs.CreditsUsed + cost151 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 }155156 vs.Votes[optionIdx] = updated157 vs.CreditsUsed = newCreditsUsed158 p.Tally[optionIdx] += delta159160 chain.Emit("VoteCast",161 "pollID", pollID,162 "voter", caller.String(),163 "option", p.Options[optionIdx],164 "votes", strconv.FormatInt(updated, 10),165 )166167 verb := "bought"168 n := delta169 if delta < 0 {170 verb = "sold back"171 n = -delta172 }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}177178// ClosePoll ends voting on a poll. Only its creator may close it.179func ClosePoll(cur realm, pollID string) string {180 caller := cur.Previous().Address()181182 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 = true190191 chain.Emit("PollClosed", "id", pollID)192193 return "poll #" + pollID + " closed"194}195196// escapeInline neutralizes markdown-active characters in untrusted text197// 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}210211func leadingOption(p *poll) (string, int64) {212 best := -1213 var bestVotes int64 = -1214 for i, v := range p.Tally {215 if v > bestVotes {216 bestVotes = v217 best = i218 }219 }220 if best < 0 {221 return "", 0222 }223 return p.Options[best], bestVotes224}225226func renderHome() string {227 var b strings.Builder228 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")233234 b.WriteString("## Polls\n\n")235 count := 0236 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 false250 })251 if count == 0 {252 b.WriteString("_no polls yet -- call `CreatePoll(question, \"opt1,opt2,...\")` to start one._\n")253 }254255 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")262263 return b.String()264}265266func 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 }271272 var b strings.Builder273 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")280281 b.WriteString("## Results\n\n")282 var total int64283 for _, v := range p.Tally {284 total += v285 }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")291292 return b.String()293}294295func 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 }301302 var b strings.Builder303 b.WriteString("# " + safeAddr + " in poll #" + p.ID + "\n\n")304305 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 }310311 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 continue316 }317 b.WriteString("- " + escapeInline(opt) + ": " + strconv.FormatInt(state.Votes[i], 10) + " vote(s)\n")318 }319320 return b.String()321}322323// Render shows the poll board at "", one poll's results at "<pollID>", or324// 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}335ClosePoll(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, pollID string) string
CreatePoll(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, question string, optionsCSV string) string
Render(path string) string
Vote(cur interface {.seal func(); Address func() .uverse.address; IsCode func() bool; IsCurrent func() bool; IsEphemeral func() bool; IsUser func() bool; IsUserCall func() bool; IsUserRun func() bool; PkgPath func() string; Previous func() .uverse.realm; String func() string; Sub func(string) .uverse.realm; Subpath func() string}, pollID string, optionIdx int, delta int64) string
Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.
vm/qrender output, sanitized (docs/render-security.md) and displayed in an empty-sandbox iframe — scripts, forms and popups cannot run. Links stay inert in-preview; right-click to open.