proposal.gno
8.69 Kb · 300 lines
1package commondao
2
3import (
4 "errors"
5 "time"
6
7 "gno.land/p/nt/avl/v0"
8)
9
10const (
11 StatusActive ProposalStatus = "active"
12 StatusPassed = "passed"
13 StatusRejected = "rejected"
14 StatusExecuted = "executed"
15 StatusFailed = "failed"
16 StatusWithdrawn = "withdrawn"
17)
18
19const (
20 ChoiceNone VoteChoice = ""
21 ChoiceYes = "YES"
22 ChoiceNo = "NO"
23 ChoiceNoWithVeto = "NO WITH VETO"
24 ChoiceAbstain = "ABSTAIN"
25)
26
27const (
28 QuorumOneThird float64 = 0.33 // percentage, checked as >= than quorum
29 QuorumMoreThanHalf = 0.51
30 QuorumTwoThirds = 0.66
31 QuorumThreeFourths = 0.75
32 QuorumFull = 1
33)
34
35// MaxCustomVoteChoices defines the maximum number of custom
36// vote choices that a proposal definition can define.
37const MaxCustomVoteChoices = 10
38
39var (
40 ErrInvalidCreatorAddress = errors.New("invalid proposal creator address")
41 ErrMaxCustomVoteChoices = errors.New("max number of custom vote choices exceeded")
42 ErrProposalDefinitionRequired = errors.New("proposal definition is required")
43 ErrNoQuorum = errors.New("no quorum")
44 ErrStatusIsNotActive = errors.New("proposal status is not active")
45)
46
47type (
48 // ProposalStatus defines a type for different proposal states.
49 ProposalStatus string
50
51 // VoteChoice defines a type for proposal vote choices.
52 VoteChoice string
53
54 // ExecFunc defines a type for functions that executes proposals.
55 ExecFunc func(realm) error
56
57 // Proposal defines a DAO proposal.
58 Proposal struct {
59 id uint64
60 status ProposalStatus
61 definition ProposalDefinition
62 creator address
63 record *VotingRecord // TODO: Add support for multiple voting records
64 statusReason string
65 voteChoices *avl.Tree // string(VoteChoice) -> struct{}
66 votingDeadline time.Time
67 createdAt time.Time
68 }
69
70 // ProposalDefinition defines an interface for custom proposal definitions.
71 // These definitions define proposal content and behavior, they esentially
72 // allow the definition for different proposal types.
73 ProposalDefinition interface {
74 // Title returns the proposal title.
75 Title() string
76
77 // Body returns proposal's body.
78 // It usually contains description or values that are specific to the proposal,
79 // like a description of the proposal's motivation or the list of values that
80 // would be applied when the proposal is approved.
81 Body() string
82
83 // VotingPeriod returns the period where votes are allowed after proposal creation.
84 // It is used to calculate the voting deadline from the proposal's creationd date.
85 VotingPeriod() time.Duration
86
87 // Tally counts the number of votes and verifies if proposal passes.
88 // It receives a voting context containing a readonly record with the votes
89 // that has been submitted for the proposal and also the list of DAO members.
90 Tally(VotingContext) (passes bool, _ error)
91 }
92
93 // Validable defines an interface for proposal definitions that require state validation.
94 // Validation is done before execution and normally also during proposal rendering.
95 Validable interface {
96 // Validate validates that the proposal is valid for the current state.
97 Validate() error
98 }
99
100 // Executable defines an interface for proposal definitions that modify state on approval.
101 // Once proposals are executed they are archived and considered finished.
102 Executable interface {
103 // Executor returns a function to execute the proposal.
104 Executor() ExecFunc
105 }
106
107 // CustomizableVoteChoices defines an interface for proposal definitions that want
108 // to customize the list of allowed voting choices.
109 CustomizableVoteChoices interface {
110 // CustomVoteChoices returns a list of valid voting choices.
111 // Choices are considered valid only when there are at least two possible choices
112 // otherwise proposal defaults to using YES, NO and ABSTAIN as valid choices.
113 CustomVoteChoices() []VoteChoice
114 }
115)
116
117// MustValidate validates that a proposal is valid for the current state or panics on error.
118func MustValidate(v Validable) {
119 if v == nil {
120 panic("validable proposal definition is nil")
121 }
122
123 if err := v.Validate(); err != nil {
124 panic(err)
125 }
126}
127
128// MustExecute executes an executable proposal or panics on error.
129func MustExecute(e Executable) {
130 if e == nil {
131 panic("executable proposal definition is nil")
132 }
133
134 fn := e.Executor()
135 if fn == nil {
136 return
137 }
138
139 if err := fn(cross); err != nil {
140 panic(err)
141 }
142}
143
144// NewProposal creates a new DAO proposal.
145func NewProposal(id uint64, creator address, d ProposalDefinition) (*Proposal, error) {
146 if d == nil {
147 return nil, ErrProposalDefinitionRequired
148 }
149
150 if !creator.IsValid() {
151 return nil, ErrInvalidCreatorAddress
152 }
153
154 now := time.Now()
155 p := &Proposal{
156 id: id,
157 status: StatusActive,
158 definition: d,
159 creator: creator,
160 record: &VotingRecord{},
161 voteChoices: avl.NewTree(),
162 votingDeadline: now.Add(d.VotingPeriod()),
163 createdAt: now,
164 }
165
166 if v, ok := d.(CustomizableVoteChoices); ok {
167 choices := v.CustomVoteChoices()
168 if len(choices) > MaxCustomVoteChoices {
169 return nil, ErrMaxCustomVoteChoices
170 }
171
172 for _, c := range choices {
173 p.voteChoices.Set(string(c), struct{}{})
174 }
175 }
176
177 // Use default voting choices when the definition returns none or a single vote choice
178 if p.voteChoices.Size() < 2 {
179 p.voteChoices.Set(string(ChoiceYes), struct{}{})
180 p.voteChoices.Set(string(ChoiceNo), struct{}{})
181 p.voteChoices.Set(string(ChoiceAbstain), struct{}{})
182 }
183 return p, nil
184}
185
186// ID returns the unique proposal identifies.
187func (p Proposal) ID() uint64 {
188 return p.id
189}
190
191// Definition returns the proposal definition.
192// Proposal definitions define proposal content and behavior.
193func (p Proposal) Definition() ProposalDefinition {
194 return p.definition
195}
196
197// Status returns the current proposal status.
198func (p Proposal) Status() ProposalStatus {
199 return p.status
200}
201
202// Creator returns the address of the account that created the proposal.
203func (p Proposal) Creator() address {
204 return p.creator
205}
206
207// CreatedAt returns the time that proposal was created.
208func (p Proposal) CreatedAt() time.Time {
209 return p.createdAt
210}
211
212// VotingRecord returns a record that contains all the votes submitted for the proposal.
213func (p Proposal) VotingRecord() *VotingRecord {
214 return p.record
215}
216
217// StatusReason returns an optional reason that lead to the current proposal status.
218// Reason is mostyl useful when a proposal fails.
219func (p Proposal) StatusReason() string {
220 return p.statusReason
221}
222
223// VotingDeadline returns the deadline after which no more votes should be allowed.
224func (p Proposal) VotingDeadline() time.Time {
225 return p.votingDeadline
226}
227
228// VoteChoices returns the list of vote choices allowed for the proposal.
229func (p Proposal) VoteChoices() []VoteChoice {
230 choices := make([]VoteChoice, 0, p.voteChoices.Size())
231 p.voteChoices.Iterate("", "", func(c string, _ any) bool {
232 choices = append(choices, VoteChoice(c))
233 return false
234 })
235 return choices
236}
237
238// HasVotingDeadlinePassed checks if the voting deadline has been met.
239func (p Proposal) HasVotingDeadlinePassed() bool {
240 return !time.Now().Before(p.VotingDeadline())
241}
242
243// Validate validates that a proposal is valid for the current state.
244// Validation is done when proposal status is active and when the definition supports validation.
245func (p Proposal) Validate() error {
246 if p.status != StatusActive {
247 return nil
248 }
249
250 if v, ok := p.definition.(Validable); ok {
251 return v.Validate()
252 }
253 return nil
254}
255
256// IsVoteChoiceValid checks if a vote choice is valid for the proposal.
257func (p Proposal) IsVoteChoiceValid(c VoteChoice) bool {
258 return p.voteChoices.Has(string(c))
259}
260
261// Tally counts votes and updates proposal status with the current outcome.
262// Proposal status is updated to "passed" when proposal is approved
263// or to "rejected" if proposal doesn't pass.
264func (p *Proposal) Tally(members MemberStorage) error {
265 if p.status != StatusActive {
266 return ErrStatusIsNotActive
267 }
268
269 ctx := MustNewVotingContext(p.VotingRecord(), members)
270 passes, err := p.Definition().Tally(ctx)
271 if err != nil {
272 return err
273 }
274
275 if passes {
276 p.status = StatusPassed
277 } else {
278 p.status = StatusRejected
279 }
280 return nil
281}
282
283// IsQuorumReached checks if a participation quorum is reach.
284func IsQuorumReached(quorum float64, r ReadonlyVotingRecord, members ReadonlyMemberStorage) bool {
285 if members.Size() <= 0 || quorum <= 0 {
286 return false
287 }
288
289 var totalCount int
290 r.IterateVotesCount(func(c VoteChoice, voteCount int) bool {
291 // Don't count explicit abstentions or invalid votes
292 if c != ChoiceNone && c != ChoiceAbstain {
293 totalCount += r.VoteCount(c)
294 }
295 return false
296 })
297
298 percentage := float64(totalCount) / float64(members.Size())
299 return percentage >= quorum
300}