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.

polls.gno

6.90 Kb · 270 lines
  1// Package polls is an on-chain poll / voting realm for gno.land.
  2//
  3// Anyone can create a poll with a question and a comma-separated list of
  4// options. Each caller address may cast exactly one vote per poll. Render
  5// draws every poll with its options, tallies as ▓░ bar charts, percentages
  6// and the total vote count.
  7package polls
  8
  9import (
 10	"chain"
 11	"strconv"
 12	"strings"
 13
 14	"gno.land/p/nym-moultest123/kit/ui/v0"
 15	"gno.land/p/nt/avl/v0"
 16)
 17
 18// selfPath is this realm's own page. A root-relative link resolves against
 19// the domain, not the realm, so every link back into it names it in full.
 20const selfPath = "/r/nym-moultest123/x/daily/polls/v1"
 21
 22// Poll is a single question with its options and per-address vote tracking.
 23type Poll struct {
 24	ID       int
 25	Question string
 26	Options  []string
 27	Tallies  []int
 28	Creator  string    // bech32 address of the poll creator
 29	voted    *avl.Tree // address string -> struct{}{}, one vote per address
 30}
 31
 32var (
 33	polls  []*Poll // index i holds the poll with ID i
 34	nextID int
 35)
 36
 37// CreatePoll registers a new poll. options is a comma-separated list; blank
 38// entries are dropped. Returns the new poll's id. Crossing function: an EOA
 39// invokes it via MsgCall; another realm via polls.CreatePoll(cross, q, opts).
 40func CreatePoll(cur realm, question string, options string) int {
 41	creator := caller(cur).String()
 42
 43	q := strings.TrimSpace(question)
 44	if q == "" {
 45		panic("question must not be empty")
 46	}
 47
 48	if len(q) > MaxQuestionLen {
 49		panic("question too long")
 50	}
 51	opts := parseOptions(options)
 52	if len(opts) < 2 {
 53		panic("a poll needs at least 2 options")
 54	}
 55	if len(opts) > MaxOptions {
 56		panic("a poll takes at most " + strconv.Itoa(MaxOptions) + " options")
 57	}
 58	for _, o := range opts {
 59		if len(o) > MaxOptionLen {
 60			panic("option too long")
 61		}
 62	}
 63	if len(polls) >= MaxPolls {
 64		panic("polls is full at " + strconv.Itoa(MaxPolls) + " polls")
 65	}
 66
 67	id := nextID
 68	nextID++
 69	p := &Poll{
 70		ID:       id,
 71		Question: q,
 72		Options:  opts,
 73		Tallies:  make([]int, len(opts)),
 74		Creator:  creator,
 75		voted:    avl.NewTree(),
 76	}
 77	polls = append(polls, p)
 78
 79	chain.Emit("PollCreated",
 80		"id", strconv.Itoa(id),
 81		"creator", creator,
 82		"options", strconv.Itoa(len(opts)),
 83	)
 84	return id
 85}
 86
 87// Vote casts one vote for optionIndex on poll pollID. Each caller address may
 88// vote at most once per poll. Crossing function.
 89func Vote(cur realm, pollID int, optionIndex int) {
 90	voter := caller(cur).String()
 91
 92	p := getPoll(pollID)
 93	if optionIndex < 0 || optionIndex >= len(p.Options) {
 94		panic("option index out of range")
 95	}
 96	if p.voted.Has(voter) {
 97		panic("address already voted on this poll")
 98	}
 99
100	p.voted.Set(voter, struct{}{})
101	p.Tallies[optionIndex]++
102
103	chain.Emit("Voted",
104		"id", strconv.Itoa(pollID),
105		"voter", voter,
106		"option", strconv.Itoa(optionIndex),
107	)
108}
109
110// parseOptions splits a comma-separated option string, trimming whitespace and
111// dropping empties. Pure logic — no realm state.
112func parseOptions(options string) []string {
113	var out []string
114	for _, raw := range strings.Split(options, ",") {
115		o := strings.TrimSpace(raw)
116		if o != "" {
117			out = append(out, o)
118		}
119	}
120	return out
121}
122
123// getPoll returns the poll with the given id or panics if it does not exist.
124func getPoll(id int) *Poll {
125	if id < 0 || id >= len(polls) {
126		panic("poll not found")
127	}
128	return polls[id]
129}
130
131// PollCount returns the number of polls created so far (read-only helper).
132func PollCount() int {
133	return len(polls)
134}
135
136const barWidth = 20
137
138// The bounds on what a creator may store. Anyone may create a poll, and the
139// root page renders the newest ones.
140const (
141	MaxPolls       = 500
142	MaxQuestionLen = 200
143	MaxOptions     = 10
144	MaxOptionLen   = 64
145	IndexRecent    = 20 // polls the root page shows, newest first
146)
147
148// bar renders a proportional ▓░ bar of fixed width for count out of total.
149func bar(count, total int) string {
150	filled := 0
151	if total > 0 {
152		filled = count * barWidth / total
153	}
154	if filled > barWidth {
155		filled = barWidth
156	}
157	return strings.Repeat("▓", filled) + strings.Repeat("░", barWidth-filled)
158}
159
160// percent returns the integer percentage of count out of total.
161func percent(count, total int) int {
162	if total == 0 {
163		return 0
164	}
165	return count * 100 / total
166}
167
168// total sums a tally slice.
169func total(tallies []int) int {
170	sum := 0
171	for _, t := range tallies {
172		sum += t
173	}
174	return sum
175}
176
177// renderPoll writes one poll's Markdown block into b.
178func renderPoll(b *strings.Builder, p *Poll) {
179	sum := total(p.Tallies)
180
181	b.WriteString("## Poll #")
182	b.WriteString(strconv.Itoa(p.ID))
183	b.WriteString(": ")
184	b.WriteString(ui.Inline(p.Question))
185	b.WriteString("\n\n")
186
187	for i, opt := range p.Options {
188		c := p.Tallies[i]
189		b.WriteString("- **")
190		b.WriteString(ui.Inline(opt))
191		b.WriteString("** `")
192		b.WriteString(bar(c, sum))
193		b.WriteString("` ")
194		b.WriteString(strconv.Itoa(percent(c, sum)))
195		b.WriteString("% (")
196		b.WriteString(strconv.Itoa(c))
197		if c == 1 {
198			b.WriteString(" vote)")
199		} else {
200			b.WriteString(" votes)")
201		}
202		b.WriteString("\n")
203	}
204
205	b.WriteString("\n_Total votes: ")
206	b.WriteString(strconv.Itoa(sum))
207	b.WriteString("_\n\n")
208}
209
210// Render produces the Markdown page for gnoweb. It is NOT a crossing function.
211//
212//   - ""          -> the IndexRecent newest polls
213//   - "page/<n>"  -> the n-th page of IndexRecent, newest first (page/1 is "")
214//   - "<id>"      -> a single poll by id
215func Render(path string) string {
216	var b strings.Builder
217
218	path = strings.TrimSpace(path)
219	page := 1
220	if rest, ok := strings.CutPrefix(path, "page/"); ok {
221		n, err := strconv.Atoi(rest)
222		// Bounded before any multiply: the page comes from the URL.
223		if err != nil || n < 1 || n > 1+(len(polls)-1)/IndexRecent {
224			return "# Polls\n\nNo such page.\n\n[← all polls](" + selfPath + ")\n"
225		}
226		page, path = n, ""
227	}
228	if path != "" {
229		id, err := strconv.Atoi(path)
230		if err != nil || id < 0 || id >= len(polls) {
231			return "# Polls\n\nPoll not found.\n"
232		}
233		b.WriteString("# Polls\n\n")
234		renderPoll(&b, polls[id])
235		b.WriteString("[← all polls](" + selfPath + ")\n")
236		return b.String()
237	}
238
239	b.WriteString("# Polls & Voting\n\n")
240	if len(polls) == 0 {
241		b.WriteString("_No polls yet. Create one with `CreatePoll(question, \"a,b,c\")`._\n")
242		return b.String()
243	}
244
245	pages := 1 + (len(polls)-1)/IndexRecent
246	b.WriteString("Total polls: ")
247	b.WriteString(strconv.Itoa(len(polls)))
248	if pages > 1 {
249		b.WriteString(", page " + strconv.Itoa(page) + " of " + strconv.Itoa(pages))
250	}
251	b.WriteString("\n\n")
252	//gnovet:ignore page-offset-overflow page is bounded by pages above
253	top := len(polls) - 1 - (page-1)*IndexRecent
254	for i := top; i >= 0 && i > top-IndexRecent; i-- {
255		renderPoll(&b, polls[i])
256	}
257	if page < pages {
258		b.WriteString("[older polls →](" + selfPath + ":page/" + strconv.Itoa(page+1) + ")\n")
259	}
260	return b.String()
261}
262
263// caller is the address that crossed into this realm, after checking that the
264// realm token is this frame's own.
265func caller(cur realm) address {
266	if !cur.IsCurrent() {
267		panic("polls: spoofed realm")
268	}
269	return cur.Previous().Address()
270}