ballots.gno
12.94 Kb · 460 lines
1// Package ballots is the Remix Lab public-ballot block: single-choice polls
2// with fixed options, an electorate captured at opening, one vote per
3// eligible address, integer quorum and deterministic tie handling.
4//
5// Lifecycle: Create (draft) -> Open (options, rules and electorate freeze;
6// the deadline starts) -> Finalize (any caller, once the deadline passed).
7// A draft can be cancelled, which removes it. Early closure of an open
8// ballot is not supported. Deadlines are only evaluated when a transaction
9// or read happens; nothing closes a ballot automatically.
10//
11// MaxBallots bounds ballots that are not finalized (drafts and open
12// ballots), so finalized history never blocks new ballots. Public ballots
13// bound neither voters nor votes; each voter pays for their own vote's
14// storage and nothing iterates over votes.
15//
16// Like every Remix Lab block, a *Box must stay in an unexported host-realm
17// variable. The host authenticates callers and passes their address; the Box
18// never trusts an address it did not receive from the host.
19package ballots
20
21import (
22 "strconv"
23
24 "gno.land/p/g1lnkytfqcjwllws63gvf0mv9yt04aswy4y9amhm/remixlab/core/v1"
25 "gno.land/p/nt/avl/v0"
26)
27
28// Hard bounds that no configuration may exceed.
29const (
30 HardMaxOptions = 20
31 HardMaxElectorate = 1000
32 HardMaxBallots = 1000
33 HardMaxTextBytes = 512
34)
35
36// Config is fixed at construction. Each ballot copies the rules it depends
37// on when it is created, so they never change underneath an open ballot.
38type Config struct {
39 MaxBallots int // drafts plus open ballots at any one time
40 MaxOptions int
41 MaxElectorate int
42 QuorumPercent int // 0..100, of the captured electorate
43 AllowAbstain bool // voters may cast an explicit abstention
44 MinDuration int64 // seconds
45 MaxDuration int64 // seconds
46 MaxTitleBytes int
47 MaxOptionBytes int
48}
49
50// DefaultConfig returns the starting limits proposed for the MVP.
51func DefaultConfig() Config {
52 return Config{
53 MaxBallots: 100,
54 MaxOptions: 20,
55 MaxElectorate: 100,
56 QuorumPercent: 0,
57 AllowAbstain: false,
58 MinDuration: 60,
59 MaxDuration: 30 * 24 * 3600,
60 MaxTitleBytes: 140,
61 MaxOptionBytes: 80,
62 }
63}
64
65// Validate checks configuration bounds.
66func (c Config) Validate() error {
67 switch {
68 case c.MaxBallots < 1 || c.MaxBallots > HardMaxBallots:
69 return core.Errorf(core.ErrInvalidInput, "maxBallots out of range")
70 case c.MaxOptions < 2 || c.MaxOptions > HardMaxOptions:
71 return core.Errorf(core.ErrInvalidInput, "maxOptions out of range")
72 case c.MaxElectorate < 1 || c.MaxElectorate > HardMaxElectorate:
73 return core.Errorf(core.ErrInvalidInput, "maxElectorate out of range")
74 case c.QuorumPercent < 0 || c.QuorumPercent > 100:
75 return core.Errorf(core.ErrInvalidInput, "quorumPercent out of range")
76 case c.MinDuration < 1 || c.MaxDuration < c.MinDuration:
77 return core.Errorf(core.ErrInvalidInput, "duration bounds")
78 case c.MaxTitleBytes < 1 || c.MaxTitleBytes > HardMaxTextBytes:
79 return core.Errorf(core.ErrInvalidInput, "maxTitleBytes out of range")
80 case c.MaxOptionBytes < 1 || c.MaxOptionBytes > HardMaxTextBytes:
81 return core.Errorf(core.ErrInvalidInput, "maxOptionBytes out of range")
82 }
83 return nil
84}
85
86// Abstain is the choice value for an abstention.
87const Abstain = -1
88
89type ballot struct {
90 id uint64
91 creator address
92 title string
93 options []string
94 duration int64
95 quorum int
96 allowAbstain bool
97
98 status core.BallotStatus
99 public bool
100 electorate *avl.Tree // address -> true; nil for public ballots
101 openedAt int64
102 deadline int64
103
104 votes *avl.Tree // address -> int choice
105 counts []int
106 abstentions int
107
108 outcome core.BallotOutcome
109 winner int
110}
111
112// Box holds a bounded set of ballots.
113type Box struct {
114 cfg Config
115 ballots *avl.Tree // zero-padded id -> *ballot
116 nextID uint64
117 active int // drafts plus open ballots
118}
119
120// NewBox creates an empty box. It panics on invalid configuration.
121func NewBox(cfg Config) *Box {
122 if err := cfg.Validate(); err != nil {
123 panic(err.Error())
124 }
125 return &Box{cfg: cfg, ballots: avl.NewTree()}
126}
127
128// Config returns the box configuration.
129func (b *Box) Config() Config { return b.cfg }
130
131// Count returns the number of ballots, finalized ones included.
132func (b *Box) Count() int { return b.ballots.Size() }
133
134// Active returns the number of drafts and open ballots.
135func (b *Box) Active() int { return b.active }
136
137// Create adds a draft ballot. Options are copied. creator must be the
138// authenticated caller.
139func (b *Box) Create(creator address, title string, options []string, duration int64) (uint64, error) {
140 if !creator.IsValid() {
141 return 0, core.Errorf(core.ErrInvalidInput, "invalid creator")
142 }
143 if b.active >= b.cfg.MaxBallots {
144 return 0, core.Errorf(core.ErrLimitExceeded, "too many active ballots")
145 }
146 if err := core.CheckText("title", title, 1, b.cfg.MaxTitleBytes, false); err != nil {
147 return 0, err
148 }
149 if len(options) < 2 {
150 return 0, core.Errorf(core.ErrInvalidInput, "at least two options required")
151 }
152 if len(options) > b.cfg.MaxOptions {
153 return 0, core.Errorf(core.ErrLimitExceeded, "too many options")
154 }
155 opts := make([]string, len(options))
156 seen := map[string]bool{}
157 for i, o := range options {
158 if err := core.CheckText("option", o, 1, b.cfg.MaxOptionBytes, false); err != nil {
159 return 0, err
160 }
161 if seen[o] {
162 return 0, core.Errorf(core.ErrInvalidInput, "duplicate option")
163 }
164 seen[o] = true
165 opts[i] = o
166 }
167 if duration < b.cfg.MinDuration || duration > b.cfg.MaxDuration {
168 return 0, core.Errorf(core.ErrInvalidInput, "duration out of range")
169 }
170 b.nextID++
171 id := b.nextID
172 b.ballots.Set(key(id), &ballot{
173 id: id,
174 creator: creator,
175 title: title,
176 options: opts,
177 duration: duration,
178 quorum: b.cfg.QuorumPercent,
179 allowAbstain: b.cfg.AllowAbstain,
180 status: core.BallotDraft,
181 counts: make([]int, len(opts)),
182 winner: -1,
183 })
184 b.active++
185 return id, nil
186}
187
188// CancelDraft removes a ballot that was never opened. The host must have
189// authorized the actor.
190func (b *Box) CancelDraft(id uint64) error {
191 if _, err := b.draft(id); err != nil {
192 return err
193 }
194 b.ballots.Remove(key(id))
195 b.active--
196 return nil
197}
198
199// Creator returns the address that created ballot id.
200func (b *Box) Creator(id uint64) (address, bool) {
201 bl := b.get(id)
202 if bl == nil {
203 return "", false
204 }
205 return bl.creator, true
206}
207
208// OpenWithElectorate opens a draft ballot restricted to voters. The list is
209// copied; later membership changes do not affect this ballot.
210func (b *Box) OpenWithElectorate(id uint64, now int64, voters []address) error {
211 bl, err := b.draft(id)
212 if err != nil {
213 return err
214 }
215 if err := bl.checkOpenTime(now); err != nil {
216 return err
217 }
218 if len(voters) == 0 {
219 return core.Errorf(core.ErrInvalidInput, "empty electorate")
220 }
221 el := avl.NewTree()
222 for _, v := range voters {
223 if !v.IsValid() {
224 return core.Errorf(core.ErrInvalidInput, "invalid voter address")
225 }
226 el.Set(v.String(), true)
227 if el.Size() > b.cfg.MaxElectorate {
228 return core.Errorf(core.ErrLimitExceeded, "electorate too large")
229 }
230 }
231 bl.electorate = el
232 bl.open(now)
233 return nil
234}
235
236// OpenPublic opens a draft ballot to any address. Percentage quorum needs a
237// known electorate, so public ballots require QuorumPercent == 0.
238func (b *Box) OpenPublic(id uint64, now int64) error {
239 bl, err := b.draft(id)
240 if err != nil {
241 return err
242 }
243 if bl.quorum != 0 {
244 return core.Errorf(core.ErrInvalidState, "public ballots cannot use a percentage quorum")
245 }
246 if err := bl.checkOpenTime(now); err != nil {
247 return err
248 }
249 bl.public = true
250 bl.open(now)
251 return nil
252}
253
254// Vote records voter's choice. voter must be the authenticated caller.
255// Votes are final.
256func (b *Box) Vote(id uint64, voter address, choice int, now int64) error {
257 bl := b.get(id)
258 if bl == nil {
259 return core.Errorf(core.ErrNotFound, "ballot")
260 }
261 if bl.status != core.BallotOpen {
262 return core.Errorf(core.ErrInvalidState, "ballot is not open")
263 }
264 if now >= bl.deadline {
265 return core.Errorf(core.ErrInvalidState, "voting period has ended")
266 }
267 if !bl.eligible(voter) {
268 return core.Errorf(core.ErrUnauthorized, "not in the electorate")
269 }
270 if bl.votes.Has(voter.String()) {
271 return core.Errorf(core.ErrConflict, "already voted")
272 }
273 switch {
274 case choice == Abstain && bl.allowAbstain:
275 bl.abstentions++
276 case choice >= 0 && choice < len(bl.options):
277 bl.counts[choice]++
278 default:
279 return core.Errorf(core.ErrInvalidInput, "invalid choice")
280 }
281 bl.votes.Set(voter.String(), choice)
282 return nil
283}
284
285// Finalize commits the result once the deadline has passed. Any caller may
286// finalize; the outcome depends only on rules fixed at opening.
287func (b *Box) Finalize(id uint64, now int64) (core.BallotView, error) {
288 bl := b.get(id)
289 if bl == nil {
290 return core.BallotView{}, core.Errorf(core.ErrNotFound, "ballot")
291 }
292 if bl.status != core.BallotOpen {
293 return core.BallotView{}, core.Errorf(core.ErrInvalidState, "ballot is not open")
294 }
295 if now < bl.deadline {
296 return core.BallotView{}, core.Errorf(core.ErrInvalidState, "deadline not reached")
297 }
298 bl.outcome, bl.winner = bl.compute()
299 bl.status = core.BallotFinalized
300 b.active--
301 return bl.view(), nil
302}
303
304// Ballot implements core.BallotReader.
305func (b *Box) Ballot(id uint64) (core.BallotView, bool) {
306 bl := b.get(id)
307 if bl == nil {
308 return core.BallotView{}, false
309 }
310 return bl.view(), true
311}
312
313// List returns up to limit ballots, newest first.
314func (b *Box) List(offset, limit int) []core.BallotView {
315 var out []core.BallotView
316 if offset < 0 || limit <= 0 {
317 return out
318 }
319 b.ballots.ReverseIterateByOffset(offset, limit, func(_ string, v any) bool {
320 out = append(out, v.(*ballot).view())
321 return false
322 })
323 return out
324}
325
326// HasVoted reports whether voter has cast a ballot (including abstention).
327func (b *Box) HasVoted(id uint64, voter address) bool {
328 bl := b.get(id)
329 return bl != nil && bl.votes != nil && bl.votes.Has(voter.String())
330}
331
332// Eligible reports whether voter belongs to an open ballot's electorate. It
333// ignores the deadline and whether voter has already voted.
334func (b *Box) Eligible(id uint64, voter address) bool {
335 bl := b.get(id)
336 return bl != nil && bl.status == core.BallotOpen && bl.eligible(voter)
337}
338
339// InElectorate reports whether voter belongs to the ballot's frozen
340// electorate (always true for public ballots once open).
341func (b *Box) InElectorate(id uint64, voter address) bool {
342 bl := b.get(id)
343 return bl != nil && bl.status != core.BallotDraft && bl.eligible(voter)
344}
345
346func (b *Box) get(id uint64) *ballot {
347 v := b.ballots.Get(key(id))
348 if v == nil {
349 return nil
350 }
351 return v.(*ballot)
352}
353
354func (b *Box) draft(id uint64) (*ballot, error) {
355 bl := b.get(id)
356 if bl == nil {
357 return nil, core.Errorf(core.ErrNotFound, "ballot")
358 }
359 if bl.status != core.BallotDraft {
360 return nil, core.Errorf(core.ErrInvalidState, "ballot already opened")
361 }
362 return bl, nil
363}
364
365// checkOpenTime rejects clocks that are unset or would overflow the
366// deadline.
367func (bl *ballot) checkOpenTime(now int64) error {
368 const maxInt64 = int64(^uint64(0) >> 1)
369 if now <= 0 || now > maxInt64-bl.duration {
370 return core.Errorf(core.ErrInvalidInput, "invalid opening time")
371 }
372 return nil
373}
374
375func (bl *ballot) open(now int64) {
376 bl.status = core.BallotOpen
377 bl.openedAt = now
378 bl.deadline = now + bl.duration
379 bl.votes = avl.NewTree()
380}
381
382func (bl *ballot) eligible(voter address) bool {
383 if bl.public {
384 return voter.IsValid()
385 }
386 return bl.electorate != nil && bl.electorate.Has(voter.String())
387}
388
389func (bl *ballot) turnout() int {
390 if bl.votes == nil {
391 return 0
392 }
393 return bl.votes.Size()
394}
395
396func (bl *ballot) electorateSize() int {
397 if bl.electorate == nil {
398 return 0
399 }
400 return bl.electorate.Size()
401}
402
403// compute derives the outcome from the frozen rules and recorded votes.
404// Integer arithmetic only: quorum is met when turnout*100 >= quorum*size.
405func (bl *ballot) compute() (core.BallotOutcome, int) {
406 turnout := bl.turnout()
407 if turnout == 0 {
408 return core.OutcomeNoVotes, -1
409 }
410 if !bl.public && turnout*100 < bl.quorum*bl.electorateSize() {
411 return core.OutcomeNoQuorum, -1
412 }
413 best, bestCount, tie := -1, 0, false
414 for i, c := range bl.counts {
415 switch {
416 case c > bestCount:
417 best, bestCount, tie = i, c, false
418 case c == bestCount && c > 0:
419 tie = true
420 }
421 }
422 if bestCount == 0 {
423 return core.OutcomeNoVotes, -1
424 }
425 if tie {
426 return core.OutcomeTie, -1
427 }
428 return core.OutcomeWinner, best
429}
430
431func (bl *ballot) view() core.BallotView {
432 opts := make([]string, len(bl.options))
433 copy(opts, bl.options)
434 counts := make([]int, len(bl.counts))
435 copy(counts, bl.counts)
436 return core.BallotView{
437 ID: bl.id,
438 Title: bl.title,
439 Options: opts,
440 Status: bl.status,
441 Public: bl.public,
442 ElectorateSize: bl.electorateSize(),
443 QuorumPercent: bl.quorum,
444 OpenedAt: bl.openedAt,
445 Deadline: bl.deadline,
446 Counts: counts,
447 Abstentions: bl.abstentions,
448 Turnout: bl.turnout(),
449 Outcome: bl.outcome,
450 Winner: bl.winner,
451 }
452}
453
454func key(id uint64) string {
455 s := strconv.FormatUint(id, 10)
456 for len(s) < 20 {
457 s = "0" + s
458 }
459 return s
460}