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.

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}