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