proposal.gno
9.00 Kb · 305 lines
1package commondao
2
3import (
4 "errors"
5 "time"
6
7 "gno.land/p/nt/bptree/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 *bptree.BPTree // 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.
129//
130// rlm is the realm authority threaded into the executor via cross(rlm).
131// MustExecute itself is not a crossing function (rlm sits in a non-first
132// parameter slot) because /p/ production code cannot declare crossing
133// functions — callers pass `cur` directly as the rlm argument.
134func MustExecute(e Executable, rlm realm) {
135 if e == nil {
136 panic("executable proposal definition is nil")
137 }
138
139 fn := e.Executor()
140 if fn == nil {
141 return
142 }
143
144 if err := fn(cross(rlm)); err != nil {
145 panic(err)
146 }
147}
148
149// NewProposal creates a new DAO proposal.
150func NewProposal(id uint64, creator address, d ProposalDefinition) (*Proposal, error) {
151 if d == nil {
152 return nil, ErrProposalDefinitionRequired
153 }
154
155 if !creator.IsValid() {
156 return nil, ErrInvalidCreatorAddress
157 }
158
159 now := time.Now()
160 p := &Proposal{
161 id: id,
162 status: StatusActive,
163 definition: d,
164 creator: creator,
165 record: &VotingRecord{},
166 voteChoices: bptree.NewBPTree32(),
167 votingDeadline: now.Add(d.VotingPeriod()),
168 createdAt: now,
169 }
170
171 if v, ok := d.(CustomizableVoteChoices); ok {
172 choices := v.CustomVoteChoices()
173 if len(choices) > MaxCustomVoteChoices {
174 return nil, ErrMaxCustomVoteChoices
175 }
176
177 for _, c := range choices {
178 p.voteChoices.Set(string(c), struct{}{})
179 }
180 }
181
182 // Use default voting choices when the definition returns none or a single vote choice
183 if p.voteChoices.Size() < 2 {
184 p.voteChoices.Set(string(ChoiceYes), struct{}{})
185 p.voteChoices.Set(string(ChoiceNo), struct{}{})
186 p.voteChoices.Set(string(ChoiceAbstain), struct{}{})
187 }
188 return p, nil
189}
190
191// ID returns the unique proposal identifies.
192func (p Proposal) ID() uint64 {
193 return p.id
194}
195
196// Definition returns the proposal definition.
197// Proposal definitions define proposal content and behavior.
198func (p Proposal) Definition() ProposalDefinition {
199 return p.definition
200}
201
202// Status returns the current proposal status.
203func (p Proposal) Status() ProposalStatus {
204 return p.status
205}
206
207// Creator returns the address of the account that created the proposal.
208func (p Proposal) Creator() address {
209 return p.creator
210}
211
212// CreatedAt returns the time that proposal was created.
213func (p Proposal) CreatedAt() time.Time {
214 return p.createdAt
215}
216
217// VotingRecord returns a record that contains all the votes submitted for the proposal.
218func (p Proposal) VotingRecord() *VotingRecord {
219 return p.record
220}
221
222// StatusReason returns an optional reason that lead to the current proposal status.
223// Reason is mostyl useful when a proposal fails.
224func (p Proposal) StatusReason() string {
225 return p.statusReason
226}
227
228// VotingDeadline returns the deadline after which no more votes should be allowed.
229func (p Proposal) VotingDeadline() time.Time {
230 return p.votingDeadline
231}
232
233// VoteChoices returns the list of vote choices allowed for the proposal.
234func (p Proposal) VoteChoices() []VoteChoice {
235 choices := make([]VoteChoice, 0, p.voteChoices.Size())
236 p.voteChoices.Iterate("", "", func(c string, _ any) bool {
237 choices = append(choices, VoteChoice(c))
238 return false
239 })
240 return choices
241}
242
243// HasVotingDeadlinePassed checks if the voting deadline has been met.
244func (p Proposal) HasVotingDeadlinePassed() bool {
245 return !time.Now().Before(p.VotingDeadline())
246}
247
248// Validate validates that a proposal is valid for the current state.
249// Validation is done when proposal status is active and when the definition supports validation.
250func (p Proposal) Validate() error {
251 if p.status != StatusActive {
252 return nil
253 }
254
255 if v, ok := p.definition.(Validable); ok {
256 return v.Validate()
257 }
258 return nil
259}
260
261// IsVoteChoiceValid checks if a vote choice is valid for the proposal.
262func (p Proposal) IsVoteChoiceValid(c VoteChoice) bool {
263 return p.voteChoices.Has(string(c))
264}
265
266// Tally counts votes and updates proposal status with the current outcome.
267// Proposal status is updated to "passed" when proposal is approved
268// or to "rejected" if proposal doesn't pass.
269func (p *Proposal) Tally(members MemberStorage) error {
270 if p.status != StatusActive {
271 return ErrStatusIsNotActive
272 }
273
274 ctx := MustNewVotingContext(p.VotingRecord(), members)
275 passes, err := p.Definition().Tally(ctx)
276 if err != nil {
277 return err
278 }
279
280 if passes {
281 p.status = StatusPassed
282 } else {
283 p.status = StatusRejected
284 }
285 return nil
286}
287
288// IsQuorumReached checks if a participation quorum is reach.
289func IsQuorumReached(quorum float64, r ReadonlyVotingRecord, members ReadonlyMemberStorage) bool {
290 if members.Size() <= 0 || quorum <= 0 {
291 return false
292 }
293
294 var totalCount int
295 r.IterateVotesCount(func(c VoteChoice, voteCount int) bool {
296 // Don't count explicit abstentions or invalid votes
297 if c != ChoiceNone && c != ChoiceAbstain {
298 totalCount += r.VoteCount(c)
299 }
300 return false
301 })
302
303 percentage := float64(totalCount) / float64(members.Size())
304 return percentage >= quorum
305}