ballot.gno
6.54 Kb · 249 lines
1// Package ballot is a Gno port of the classic Solidity "Ballot" voting
2// contract from the Solidity docs. A chairperson seeds a fixed list of
3// proposals and grants specific addresses the right to vote; each voter can
4// either cast a direct vote or delegate their weight to another voter, and
5// delegation follows a chain so a delegate who later delegates further still
6// carries every accumulated weight. The winning proposal is whichever has
7// the most accumulated weight at query time.
8package ballot
9
10import (
11 "errors"
12 "strconv"
13 "strings"
14
15 "gno.land/p/nt/avl/v0"
16)
17
18// proposal is one of the fixed choices set up at Init time.
19type proposal struct {
20 name string
21 votes uint64
22}
23
24// voterInfo tracks one address's voting rights and delegation state.
25type voterInfo struct {
26 weight uint64 // 0 means "no right to vote"
27 voted bool // true once the weight has been cast (directly or delegated away)
28 delegate address // set only when voted == true and this voter delegated
29 vote int // proposal index this voter's weight ended up on, valid once tallied
30}
31
32// Package-level persistent state.
33var (
34 chairperson address
35 initialized bool
36 proposals []proposal
37 voters *avl.Tree // address string -> *voterInfo
38)
39
40var (
41 errAlreadyInit = errors.New("ballot: already initialized")
42 errNotInit = errors.New("ballot: not initialized yet")
43 errNotChairperson = errors.New("ballot: only the chairperson may do this")
44 errNoProposals = errors.New("ballot: at least one proposal name is required")
45 errInvalidAddr = errors.New("ballot: invalid address")
46 errAlreadyRight = errors.New("ballot: voter already has the right to vote")
47 errAlreadyVoted = errors.New("ballot: voter already voted")
48 errNoRight = errors.New("ballot: caller has no right to vote")
49 errSelfDelegate = errors.New("ballot: self-delegation is not allowed")
50 errDelegateLoop = errors.New("ballot: found a loop in the delegation chain")
51 errDelegateNoRight = errors.New("ballot: delegate has no right to vote")
52 errBadProposal = errors.New("ballot: invalid proposal index")
53)
54
55func init() {
56 voters = avl.NewTree()
57}
58
59func requireInit() {
60 if !initialized {
61 panic(errNotInit)
62 }
63}
64
65func getOrCreateVoter(addr address) *voterInfo {
66 if v := voters.Get(addr.String()); v != nil {
67 return v.(*voterInfo)
68 }
69 v := &voterInfo{}
70 voters.Set(addr.String(), v)
71 return v
72}
73
74// Init configures the ballot exactly once: the caller becomes the
75// chairperson (and is immediately given the right to vote), and
76// proposalNamesCSV — a comma-separated list — seeds the fixed set of
77// proposals. It panics if called twice or with no usable proposal names.
78func Init(cur realm, proposalNamesCSV string) {
79 if initialized {
80 panic(errAlreadyInit)
81 }
82
83 for _, raw := range strings.Split(proposalNamesCSV, ",") {
84 name := strings.TrimSpace(raw)
85 if name == "" {
86 continue
87 }
88 proposals = append(proposals, proposal{name: name})
89 }
90 if len(proposals) == 0 {
91 panic(errNoProposals)
92 }
93
94 chairperson = cur.Previous().Address()
95 voters.Set(chairperson.String(), &voterInfo{weight: 1})
96 initialized = true
97}
98
99// GiveRightToVote grants voterAddr a voting weight of 1. Only the
100// chairperson may call this, and only for voters who haven't voted yet.
101func GiveRightToVote(cur realm, voterAddr string) {
102 requireInit()
103 if cur.Previous().Address() != chairperson {
104 panic(errNotChairperson)
105 }
106
107 addr := address(voterAddr)
108 if !addr.IsValid() {
109 panic(errInvalidAddr)
110 }
111
112 v := getOrCreateVoter(addr)
113 if v.voted {
114 panic(errAlreadyVoted)
115 }
116 if v.weight != 0 {
117 panic(errAlreadyRight)
118 }
119 v.weight = 1
120}
121
122// Delegate hands the caller's voting weight to toAddr. If toAddr has
123// already voted, the weight is added directly to that proposal's tally;
124// otherwise it accumulates on toAddr's own weight, ready to be cast (or
125// delegated further) later. Mirrors Solidity's delegation-chain-following
126// with a loop guard.
127func Delegate(cur realm, toAddr string) {
128 requireInit()
129
130 sender := cur.Previous().Address()
131 from := getOrCreateVoter(sender)
132 if from.weight == 0 {
133 panic(errNoRight)
134 }
135 if from.voted {
136 panic(errAlreadyVoted)
137 }
138
139 to := address(toAddr)
140 if !to.IsValid() {
141 panic(errInvalidAddr)
142 }
143 if to == sender {
144 panic(errSelfDelegate)
145 }
146
147 var zero address
148 maxSteps := voters.Size() + 1
149 for steps := 0; ; steps++ {
150 v := voters.Get(to.String())
151 if v == nil {
152 break
153 }
154 next := v.(*voterInfo).delegate
155 if next == zero {
156 break
157 }
158 to = next
159 if to == sender {
160 panic(errDelegateLoop)
161 }
162 if steps > maxSteps {
163 panic(errDelegateLoop)
164 }
165 }
166
167 delegateVoter := getOrCreateVoter(to)
168 if delegateVoter.weight == 0 {
169 panic(errDelegateNoRight)
170 }
171
172 from.voted = true
173 from.delegate = to
174
175 if delegateVoter.voted {
176 proposals[delegateVoter.vote].votes += from.weight
177 } else {
178 delegateVoter.weight += from.weight
179 }
180}
181
182// Vote casts the caller's full weight for proposals[proposalIndex].
183func Vote(cur realm, proposalIndex int) {
184 requireInit()
185
186 sender := cur.Previous().Address()
187 v := getOrCreateVoter(sender)
188 if v.weight == 0 {
189 panic(errNoRight)
190 }
191 if v.voted {
192 panic(errAlreadyVoted)
193 }
194 if proposalIndex < 0 || proposalIndex >= len(proposals) {
195 panic(errBadProposal)
196 }
197
198 v.voted = true
199 v.vote = proposalIndex
200 proposals[proposalIndex].votes += v.weight
201}
202
203// WinningProposal returns the index of the proposal with the most
204// accumulated weight, breaking ties in favor of the lowest index.
205func WinningProposal() int {
206 winner := 0
207 best := uint64(0)
208 for i, p := range proposals {
209 if p.votes > best {
210 best = p.votes
211 winner = i
212 }
213 }
214 return winner
215}
216
217// WinnerName returns the name of the current winning proposal, or "" if
218// the ballot hasn't been initialized.
219func WinnerName() string {
220 if len(proposals) == 0 {
221 return ""
222 }
223 return proposals[WinningProposal()].name
224}
225
226func Render(path string) string {
227 var b strings.Builder
228 b.WriteString("# Ballot\n\n")
229
230 if !initialized {
231 b.WriteString("Not initialized yet. The first caller to run `Init` becomes the " +
232 "chairperson and seeds the proposal list, e.g.:\n\n")
233 b.WriteString("```\nInit(\"Alice,Bob,Carol\")\n```\n")
234 return b.String()
235 }
236
237 b.WriteString("Chairperson: `" + chairperson.String() + "`\n\n")
238 b.WriteString("| # | Proposal | Votes |\n")
239 b.WriteString("|---|---|---|\n")
240 for i, p := range proposals {
241 b.WriteString("| " + strconv.Itoa(i) + " | " + p.name + " | " +
242 strconv.FormatUint(p.votes, 10) + " |\n")
243 }
244
245 b.WriteString("\nCurrent winner: **" + WinnerName() + "** (index " +
246 strconv.Itoa(WinningProposal()) + ")\n\n")
247 b.WriteString("Registered voters: " + strconv.Itoa(voters.Size()) + "\n")
248 return b.String()
249}