Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}