commondao.gno
18.18 Kb · 576 lines
1package commondao
2
3import (
4 "errors"
5
6 "gno.land/p/nt/addrset/v0"
7 "gno.land/p/nt/bptree/list/v0"
8 "gno.land/p/nt/bptree/v0"
9 "gno.land/p/nt/seqid/v0"
10)
11
12// DefaultMaxActiveProposals is the default cap for simultaneously active
13// proposals per DAO. Every active proposal stores a council snapshot, so
14// the cap bounds storage. It is applied at construction; a hosting realm
15// may override it per DAO via SetMaxActiveProposals (the reference realm
16// keeps this default).
17const DefaultMaxActiveProposals = 32
18
19var (
20 ErrCouncilUpdateOverlap = errors.New("council update adds and removes the same address")
21 ErrDAOIsDeleted = errors.New("DAO is deleted")
22 ErrEmptyCouncil = errors.New("council update would remove every council member")
23 ErrExecutionNotAllowed = errors.New("proposal must be active or passed to be executed")
24 ErrInvalidVoteChoice = errors.New("invalid vote choice")
25 ErrMaxActiveProposals = errors.New("max number of active proposals reached")
26 ErrMaxCapExemptProposals = errors.New("creator already has an active cap exempt proposal")
27 ErrNotElectorateMember = errors.New("account is not a member of the proposal's electorate")
28 ErrOverflow = errors.New("next ID overflows uint64")
29 ErrProposalKindExists = errors.New("proposal kind already registered")
30 ErrProposalKindNotFound = errors.New("proposal kind not found")
31 ErrProposalKindRequired = errors.New("proposal kind is required")
32 ErrProposalNotFound = errors.New("proposal not found")
33 ErrVotingDeadlineNotMet = errors.New("voting deadline not met")
34 ErrVotingDeadlinePassed = errors.New("voting deadline has passed")
35 ErrWithdrawalNotAllowed = errors.New("withdrawal not allowed for proposals with votes")
36)
37
38// CommonDAO defines a DAO.
39//
40// # Security
41//
42// A *CommonDAO is a mutable handle: its exported mutators (UpdateCouncil,
43// Dissolve, Propose, Vote, Execute, Withdraw, SetTreasuryFrozen,
44// SetMaxActiveProposals, RegisterKind, DeregisterKind) are meant for the
45// realm that owns the DAO.
46// Three rules apply at realm boundaries:
47//
48// 1. Do not ACCEPT a *CommonDAO from an external/untrusted caller.
49// 2. Do not RETURN a *CommonDAO from any function callable by untrusted
50// realms — return dao.Readonly() (a ReadonlyCommonDAO view) instead.
51// 3. Do not TRUST a readonly view received from an untrusted caller: it
52// is a live handle over the sender's data.
53type CommonDAO struct {
54 id uint64
55 name string
56 description string
57 purpose string
58 addr address // derived treasury address, empty when unset
59 parent *CommonDAO
60 children list.IList
61 council *addrset.Set
62 genID seqid.ID
63 kinds *bptree.BPTree // proposal kind name -> ProposalKind
64 activeProposals *proposalStorage
65 finishedProposals *proposalStorage
66 deleted bool // Soft delete
67 treasuryFrozen bool
68 maxActiveProposals int
69 proposing bool // re-entrancy latch around a kind's New in Propose
70 executing bool // re-entrancy latch around Execute
71}
72
73// New creates a new common DAO.
74func New(options ...Option) *CommonDAO {
75 dao := &CommonDAO{
76 children: &list.List{},
77 council: &addrset.Set{},
78 kinds: bptree.NewBPTree32(),
79 activeProposals: newProposalStorage(),
80 finishedProposals: newProposalStorage(),
81 maxActiveProposals: DefaultMaxActiveProposals,
82 }
83 for _, apply := range options {
84 apply(dao)
85 }
86 return dao
87}
88
89// ID returns DAO's unique identifier.
90func (dao CommonDAO) ID() uint64 {
91 return dao.id
92}
93
94// Name returns DAO's name.
95func (dao CommonDAO) Name() string {
96 return dao.name
97}
98
99// Purpose returns the DAO's purpose. Together with the description it
100// forms the DAO's Charter (docs/CONSTITUTION.md :1485).
101func (dao CommonDAO) Purpose() string {
102 return dao.purpose
103}
104
105// Description returns DAO's description.
106func (dao CommonDAO) Description() string {
107 return dao.description
108}
109
110// Address returns the DAO's treasury address, assigned at creation with
111// WithAddress. The package never derives or uses the address itself:
112// hosting realms derive it (e.g. from a realm sub-identity) and operate
113// its funds through their own banker. Empty when unset.
114func (dao CommonDAO) Address() address {
115 return dao.addr
116}
117
118// IsTreasuryFrozen checks if the DAO's treasury is frozen. The package
119// stores the flag only; hosting realms enforce it when moving funds.
120func (dao CommonDAO) IsTreasuryFrozen() bool {
121 return dao.treasuryFrozen
122}
123
124// SetTreasuryFrozen freezes or unfreezes the DAO's treasury.
125func (dao *CommonDAO) SetTreasuryFrozen(frozen bool) {
126 dao.treasuryFrozen = frozen
127}
128
129// Parent returns the parent DAO.
130// Null can be returned when DAO has no parent assigned.
131func (dao CommonDAO) Parent() *CommonDAO {
132 return dao.parent
133}
134
135// ChildrenCount returns the number of direct children DAOs.
136func (dao CommonDAO) ChildrenCount() int {
137 return dao.children.Len()
138}
139
140// IterateChildren iterates the direct children DAOs.
141func (dao CommonDAO) IterateChildren(fn func(*CommonDAO) bool) (stopped bool) {
142 dao.children.ForEach(func(_ int, v any) bool {
143 stopped = fn(v.(*CommonDAO))
144 return stopped
145 })
146 return stopped
147}
148
149// Council returns a read only view of the DAO council.
150//
151// The council is the set of addresses entitled to vote. It changes only
152// through UpdateCouncil (normally called by a council update proposal
153// executor) or constructor options.
154func (dao CommonDAO) Council() *addrset.ReadonlySet {
155 return dao.council.Readonly()
156}
157
158// UpdateCouncil adds and removes council members as idempotent set
159// operations: adding an existing member or removing an absent one is a
160// no-op, so concurrently passed council updates merge deterministically in
161// execution order, and a full council replacement in a single update is
162// legal.
163//
164// The final set is (council ∪ add) \ remove. An update that adds and
165// removes the same address is rejected, and an update whose final set
166// would empty a non-empty council returns ErrEmptyCouncil: executors must
167// propagate the error (failing the proposal) instead of panicking, which
168// would revert the transaction and leave the proposal stuck.
169func (dao *CommonDAO) UpdateCouncil(add, remove []address) error {
170 for _, a := range add {
171 for _, r := range remove {
172 if a == r {
173 return ErrCouncilUpdateOverlap
174 }
175 }
176 }
177
178 // The final set can only be empty when nothing is added: overlap is
179 // rejected above, so any added address survives its own update.
180 if dao.council.Size() > 0 && len(add) == 0 {
181 empty := true
182 dao.council.IterateByOffset(0, dao.council.Size(), func(member address) bool {
183 for _, r := range remove {
184 if r == member {
185 return false // removed: keep looking for a survivor
186 }
187 }
188 empty = false
189 return true
190 })
191 if empty {
192 return ErrEmptyCouncil
193 }
194 }
195
196 for _, a := range add {
197 dao.council.Add(a)
198 }
199 for _, r := range remove {
200 dao.council.Remove(r)
201 }
202 return nil
203}
204
205// ActiveProposalsSize returns the number of active proposals, including
206// early passed proposals that were not executed yet.
207func (dao CommonDAO) ActiveProposalsSize() int {
208 return dao.activeProposals.Size()
209}
210
211// IterateActiveProposals iterates active proposals ordered by ID.
212func (dao CommonDAO) IterateActiveProposals(offset, count int, reverse bool, fn func(*Proposal) bool) bool {
213 return dao.activeProposals.Iterate(offset, count, reverse, fn)
214}
215
216// FinishedProposalsSize returns the number of finished proposals.
217func (dao CommonDAO) FinishedProposalsSize() int {
218 return dao.finishedProposals.Size()
219}
220
221// IterateFinishedProposals iterates finished proposals ordered by ID.
222func (dao CommonDAO) IterateFinishedProposals(offset, count int, reverse bool, fn func(*Proposal) bool) bool {
223 return dao.finishedProposals.Iterate(offset, count, reverse, fn)
224}
225
226// IsDeleted returns true when DAO has been soft deleted.
227func (dao CommonDAO) IsDeleted() bool {
228 return dao.deleted
229}
230
231// MaxActiveProposals returns the cap for simultaneously active proposals.
232func (dao CommonDAO) MaxActiveProposals() int {
233 return dao.maxActiveProposals
234}
235
236// SetMaxActiveProposals changes the cap for simultaneously active
237// proposals. Values below one are ignored: a DAO must always be able to
238// propose.
239func (dao *CommonDAO) SetMaxActiveProposals(max int) {
240 if max >= 1 {
241 dao.maxActiveProposals = max
242 }
243}
244
245// RegisterKind registers a proposal kind by its name.
246//
247// Registered kinds are the only way to create proposals: Propose looks
248// kinds up by name and calls their New factory, so a proposal type is
249// proposable iff its kind is registered. Like SetTreasuryFrozen, this
250// mutator is meant for the realm that owns the DAO (typically called at
251// DAO creation and by governance proposal executors).
252func (dao *CommonDAO) RegisterKind(k ProposalKind) error {
253 if k == nil || k.Name() == "" {
254 return ErrProposalKindRequired
255 }
256 if dao.kinds.Has(k.Name()) {
257 return ErrProposalKindExists
258 }
259 dao.kinds.Set(k.Name(), k)
260 return nil
261}
262
263// DeregisterKind removes a proposal kind by name.
264//
265// Deregistering only blocks new proposals: the registry is read at
266// Propose time only, so in-flight proposals of the kind keep their
267// frozen definition and still vote and execute.
268//
269// This is a plain registry primitive with no reserved names: any
270// registered kind can be removed. A consuming realm that must keep a
271// kind un-removable (e.g. a governance kind that manages the kind set)
272// enforces that as its own policy, not through this package.
273func (dao *CommonDAO) DeregisterKind(name string) error {
274 if _, removed := dao.kinds.Remove(name); !removed {
275 return ErrProposalKindNotFound
276 }
277 return nil
278}
279
280// HasKind checks if a proposal kind is registered.
281func (dao CommonDAO) HasKind(name string) bool {
282 return dao.kinds.Has(name)
283}
284
285// KindNames returns the names of the registered proposal kinds, sorted.
286func (dao CommonDAO) KindNames() []string {
287 names := make([]string, 0, dao.kinds.Size())
288 dao.kinds.IterateByOffset(0, dao.kinds.Size(), func(name string, _ any) bool {
289 names = append(names, name)
290 return false
291 })
292 return names
293}
294
295// Propose creates a new DAO proposal.
296//
297// Proposals are created through registered proposal kinds: the kind is
298// looked up by name in the DAO's registry and its New factory builds the
299// proposal definition from args. The registry is read only here and the
300// definition is frozen once the proposal is created, so deregistering a
301// kind later never touches in-flight proposals.
302//
303// The proposal's electorate is the council snapshot taken now: members
304// added later vote on the next proposal; members removed later remain in
305// the electorate, where their silence counts against passage.
306//
307// The number of simultaneously active proposals is capped. Definitions
308// implementing CapExempt (e.g. council updates, which must never be
309// blockable by a full cap) are exempt but bounded to one active proposal
310// per creator.
311func (dao *CommonDAO) Propose(creator address, kind string, args any) (*Proposal, error) {
312 if dao.deleted {
313 return nil, ErrDAOIsDeleted
314 }
315
316 v := dao.kinds.Get(kind)
317 if v == nil {
318 return nil, ErrProposalKindNotFound
319 }
320
321 // Re-entrancy latch: a kind's New must not trigger another Propose on
322 // this DAO (e.g. via a captured handle), which could nest factory
323 // calls or grow active storage unboundedly before the first returns.
324 if dao.proposing {
325 panic("commondao: re-entrant Propose is not allowed")
326 }
327 dao.proposing = true
328 // Deferred so a panicking New cannot leave the latch stuck (which would
329 // brick every future Propose on this DAO for a consumer that recovers
330 // the panic within the transaction); mirrors the executing latch.
331 defer func() { dao.proposing = false }()
332 d, err := v.(ProposalKind).New(dao.Readonly(), args)
333 if err != nil {
334 return nil, err
335 }
336
337 if d == nil {
338 return nil, ErrProposalDefinitionRequired
339 }
340
341 if _, exempt := d.(CapExempt); exempt {
342 var found bool
343 dao.activeProposals.Iterate(0, dao.activeProposals.Size(), false, func(p *Proposal) bool {
344 if _, ok := p.definition.(CapExempt); ok && p.creator == creator {
345 found = true
346 return true
347 }
348 return false
349 })
350 if found {
351 return nil, ErrMaxCapExemptProposals
352 }
353 } else if dao.activeProposals.Size() >= dao.maxActiveProposals {
354 return nil, ErrMaxActiveProposals
355 }
356
357 id, ok := dao.genID.TryNext()
358 if !ok {
359 return nil, ErrOverflow
360 }
361
362 p, err := newProposal(uint64(id), creator, d)
363 if err != nil {
364 return nil, err
365 }
366
367 // Snapshot the current council as the proposal's electorate
368 dao.council.IterateByOffset(0, dao.council.Size(), func(member address) bool {
369 p.electorate.Add(member)
370 return false
371 })
372
373 dao.activeProposals.Add(p)
374 return p, nil
375}
376
377// GetProposal returns a proposal or nil when proposal is not found.
378func (dao CommonDAO) GetProposal(proposalID uint64) *Proposal {
379 p := dao.activeProposals.Get(proposalID)
380 if p != nil {
381 return p
382 }
383 return dao.finishedProposals.Get(proposalID)
384}
385
386// Withdraw withdraws a proposal that has no votes.
387// Only active proposals without votes can be withdrawn, and once
388// withdrawn they are considered finished.
389func (dao *CommonDAO) Withdraw(proposalID uint64) error {
390 p := dao.activeProposals.Get(proposalID)
391 if p == nil {
392 return ErrProposalNotFound
393 }
394
395 if p.status != StatusActive {
396 return ErrStatusIsNotActive
397 }
398
399 if p.record.Size() > 0 {
400 return ErrWithdrawalNotAllowed
401 }
402
403 p.status = StatusWithdrawn
404 dao.activeProposals.Remove(p.id)
405 dao.finishedProposals.Add(p)
406 return nil
407}
408
409// Vote submits a new vote for a proposal.
410//
411// Votes are only allowed to members of the proposal's electorate while the
412// proposal is active and within the voting period. A member may change
413// their vote by voting again.
414//
415// Proposals are re-evaluated after every recorded vote: a YES tally at
416// the definition's threshold decides the proposal immediately, and a
417// simple majority of NO dismisses it immediately.
418func (dao *CommonDAO) Vote(member address, proposalID uint64, c VoteChoice, reason string) error {
419 if dao.deleted {
420 return ErrDAOIsDeleted
421 }
422
423 p := dao.activeProposals.Get(proposalID)
424 if p == nil {
425 return ErrProposalNotFound
426 }
427
428 if p.status != StatusActive {
429 return ErrStatusIsNotActive
430 }
431
432 if !p.electorate.Has(member) {
433 return ErrNotElectorateMember
434 }
435
436 if p.HasVotingDeadlinePassed() {
437 return ErrVotingDeadlinePassed
438 }
439
440 if c != ChoiceYes && c != ChoiceNo && c != ChoiceAbstain {
441 return ErrInvalidVoteChoice
442 }
443
444 p.record.AddVote(Vote{
445 addr: member,
446 choice: c,
447 reason: reason,
448 })
449
450 // Early termination: proposals are decided the moment the outcome is
451 // mathematically settled. A passed proposal stays in the active
452 // storage until executed; a dismissed one is finished.
453 switch TallyDefault(p.record.Readonly(), p.Electorate(), p.definition.Threshold()) {
454 case OutcomePassed:
455 p.status = StatusPassed
456 case OutcomeDismissed:
457 dao.dismiss(p)
458 }
459 return nil
460}
461
462// Execute executes a proposal.
463//
464// Proposals that already passed (decided early by the default Council
465// rules) execute immediately. Active proposals are tallied once their
466// voting deadline passes and are dismissed unless passed.
467//
468// sub is the DAO-scoped sub-identity that the host mints and passes into
469// the executor as its value-movement authority (see ExecFunc). The
470// executor is non-crossing, so it is called directly. Execute itself is
471// not a crossing function (sub sits in a non-first parameter slot)
472// because /p/ production code cannot declare crossing functions.
473func (dao *CommonDAO) Execute(proposalID uint64, sub realm) error {
474 if dao.deleted {
475 return ErrDAOIsDeleted
476 }
477
478 // Re-entrancy latch: an executor must not re-enter Execute on this
479 // DAO. Remove-before-run already stops the same proposal from running
480 // twice; this additionally blocks an executor from executing a
481 // different proposal of the same DAO mid-execution.
482 if dao.executing {
483 panic("commondao: re-entrant Execute is not allowed")
484 }
485 dao.executing = true
486 defer func() { dao.executing = false }()
487
488 p := dao.activeProposals.Get(proposalID)
489 if p == nil {
490 return ErrProposalNotFound
491 }
492
493 switch p.status {
494 case StatusPassed:
495 // Decided early: execute now, before the voting deadline
496 case StatusActive:
497 if !p.HasVotingDeadlinePassed() {
498 return ErrVotingDeadlineNotMet
499 }
500 default:
501 return ErrExecutionNotAllowed
502 }
503
504 // The proposal leaves active storage before any definition code
505 // (Validate, the executor) runs, so a re-entrant Execute call
506 // cannot run it twice.
507 dao.activeProposals.Remove(p.id)
508
509 // Tally proposals that are still active after their deadline;
510 // undecided proposals are dismissed. Vote already decides settled
511 // outcomes, so this re-tally only matters for definitions whose
512 // Threshold is not constant.
513 if p.status == StatusActive {
514 if TallyDefault(p.record.Readonly(), p.Electorate(), p.definition.Threshold()) == OutcomePassed {
515 p.status = StatusPassed
516 } else {
517 p.status = StatusDismissed
518 dao.finishedProposals.Add(p)
519 return nil
520 }
521 }
522
523 // IMPORTANT, from this point on, any error is going to result
524 // in a proposal failure and execute will succeed.
525
526 // Validate the passed proposal before execution
527 err := p.Validate()
528
529 // Execute proposal only if it's executable
530 if err == nil {
531 if e, ok := p.Definition().(Executable); ok {
532 if fn := e.Executor(); fn != nil {
533 err = fn(0, sub)
534 }
535 }
536 }
537
538 // Proposal fails if there is any error during validation and execution process
539 if err != nil {
540 p.status = StatusFailed
541 p.statusReason = err.Error()
542 } else {
543 p.status = StatusExecuted
544 p.statusReason = ""
545 }
546
547 // Whichever the outcome of the validation, tallying
548 // and execution consider the proposal finished.
549 dao.finishedProposals.Add(p)
550 return nil
551}
552
553// dismiss finishes a proposal as dismissed.
554func (dao *CommonDAO) dismiss(p *Proposal) {
555 p.status = StatusDismissed
556 dao.activeProposals.Remove(p.id)
557 dao.finishedProposals.Add(p)
558}
559
560// Dissolve soft deletes the DAO after dismissing every in-flight proposal
561// (both still-active and passed-but-unexecuted ones). Dissolution is
562// terminal: a deleted DAO rejects proposals, votes and executions, so
563// nothing may remain pending.
564func (dao *CommonDAO) Dissolve(reason string) {
565 var pending []*Proposal
566 dao.activeProposals.Iterate(0, dao.activeProposals.Size(), false, func(p *Proposal) bool {
567 pending = append(pending, p)
568 return false
569 })
570
571 for _, p := range pending {
572 p.statusReason = reason
573 dao.dismiss(p)
574 }
575 dao.deleted = true
576}