proposal_kinds.gno
20.63 Kb · 535 lines
1package commondao
2
3import (
4 "chain"
5 "errors"
6 "strings"
7 "time"
8
9 "gno.land/p/moul/md/v0"
10 "gno.land/p/nt/commondao/v0"
11)
12
13// Proposal kind names: the per-DAO registry keys for every proposal type
14// this realm can host. A DAO accepts proposals of exactly the kinds
15// registered on it. The default kinds are registered at DAO creation and
16// the kind set is managed afterwards through the manage-kinds kind
17// (CreateRegisterKindProposal / CreateDeregisterKindProposal).
18const (
19 kindText = "text"
20 kindCouncilUpdate = "council-update"
21 kindAncestorCouncilUpdate = "ancestor-council-update"
22 kindSubDAO = "subdao"
23 kindDissolve = "dissolve"
24 kindTreasurySpend = "treasury-spend"
25 kindTreasuryClawback = "treasury-clawback"
26 kindTreasuryFreeze = "treasury-freeze"
27 kindManageKinds = "manage-kinds"
28 kindAmendBylaws = "amend-bylaws"
29
30 // Opt-in kind. It is part of the catalog (so catalogKind resolves it and
31 // manage-kinds can register it by name) but is NOT default-seeded: a DAO
32 // gains it only after a supermajority register.
33 //
34 // It is a realm-side kind (executionKind), not the bare /p/
35 // commondao.ExecutionKind: the realm wraps the arbitrary-exec closure with
36 // its own Validable freeze policy so a frozen DAO cannot drain its own
37 // treasury through an execution proposal (the /p/ kind carries no such
38 // policy). The name still matches the /p/ kind's name.
39 kindExecution = "execution"
40)
41
42// errInvalidProposalArgs reports a proposal kind invoked with the wrong
43// args type. Wrappers and kinds live in the same package, so this only
44// fires on a realm bug, never on user input.
45var errInvalidProposalArgs = errors.New("invalid proposal arguments")
46
47// defaultProposalKinds lists the proposal kinds seeded on every new DAO
48// (genesis, user-created or sub-DAO). Kinds are stateless singletons that
49// wrap the definition constructors. This is the DAO's starting governance
50// surface; the kind set is managed afterwards through the manage-kinds kind.
51// Ordering here is immaterial — render.gno owns the presentation order.
52var defaultProposalKinds = []commondao.ProposalKind{
53 textKind{},
54 councilUpdateKind{},
55 ancestorCouncilUpdateKind{},
56 subDAOKind{},
57 dissolveKind{},
58 treasurySpendKind{},
59 treasuryClawbackKind{},
60 treasuryFreezeKind{},
61 manageKindsKind{},
62 amendBylawsKind{},
63}
64
65// optInProposalKinds lists the opt-in proposal kinds: they are part of the
66// catalog but NOT default-seeded, so a DAO gains them only after a
67// supermajority manage-kinds register. The execution kind is realm-side
68// (executionKind), not the bare /p/ ExecutionKind, so it can carry the
69// realm's freeze policy (see kindExecution).
70var optInProposalKinds = []commondao.ProposalKind{
71 executionKind{},
72}
73
74// proposalKindCatalog lists every proposal kind this realm can host: the
75// default-seeded kinds plus the opt-in kind (execution). catalogKind
76// resolves against this list, so manage-kinds can register an opt-in kind by
77// name that is not seeded. The executor set stays closed because only catalog
78// kinds are ever registered on a DAO by name. Ordering here is immaterial —
79// render.gno owns the presentation order.
80var proposalKindCatalog = append(
81 append([]commondao.ProposalKind{}, defaultProposalKinds...),
82 optInProposalKinds...,
83)
84
85// catalogKind returns a catalog kind by name, or nil when the name is not
86// part of the catalog.
87func catalogKind(name string) commondao.ProposalKind {
88 for _, k := range proposalKindCatalog {
89 if k.Name() == name {
90 return k
91 }
92 }
93 return nil
94}
95
96// HasProposalKind reports whether a proposal kind is registered on a DAO.
97func HasProposalKind(daoID uint64, name string) bool {
98 return mustGetDAO(daoID).HasKind(name)
99}
100
101// textArgs carries CreateTextProposal parameters to the text kind.
102type textArgs struct {
103 title string
104 body string
105 votingPeriod time.Duration
106}
107
108// textKind creates general text proposals.
109type textKind struct{}
110
111func (textKind) Name() string { return kindText }
112
113func (textKind) New(_ commondao.ReadonlyCommonDAO, args any) (commondao.ProposalDefinition, error) {
114 a, ok := args.(textArgs)
115 if !ok {
116 return nil, errInvalidProposalArgs
117 }
118 return newTextPropDefinition(a.title, a.body, a.votingPeriod), nil
119}
120
121// councilUpdateArgs carries CreateCouncilUpdateProposal parameters to the
122// council-update kind. dao is the host DAO whose own council the executor
123// mutates: New receives only a readonly view, so the trusted wrapper passes
124// the mutable host handle through args (captured from its own mustGetDAO).
125type councilUpdateArgs struct {
126 dao *commondao.CommonDAO
127 add []address
128 remove []address
129}
130
131// councilUpdateKind creates proposals that add and/or remove members of
132// the host DAO's own council.
133type councilUpdateKind struct{}
134
135func (councilUpdateKind) Name() string { return kindCouncilUpdate }
136
137func (councilUpdateKind) New(_ commondao.ReadonlyCommonDAO, args any) (commondao.ProposalDefinition, error) {
138 a, ok := args.(councilUpdateArgs)
139 if !ok {
140 return nil, errInvalidProposalArgs
141 }
142 return newCouncilUpdatePropDefinition(a.dao, a.add, a.remove), nil
143}
144
145// ancestorCouncilUpdateArgs carries CreateAncestorCouncilUpdateProposal
146// parameters to the ancestor-council-update kind. host is the proposing
147// ancestor DAO (read for the ancestry check); target is the descendant
148// whose council the executor mutates. Both handles come from the trusted
149// wrapper via args, since New receives only a readonly view.
150type ancestorCouncilUpdateArgs struct {
151 host *commondao.CommonDAO
152 target *commondao.CommonDAO
153 add []address
154 remove []address
155}
156
157// ancestorCouncilUpdateKind creates proposals for the host DAO, as an
158// ancestor, to add and/or remove members of a descendant's council.
159type ancestorCouncilUpdateKind struct{}
160
161func (ancestorCouncilUpdateKind) Name() string { return kindAncestorCouncilUpdate }
162
163func (ancestorCouncilUpdateKind) New(_ commondao.ReadonlyCommonDAO, args any) (commondao.ProposalDefinition, error) {
164 a, ok := args.(ancestorCouncilUpdateArgs)
165 if !ok {
166 return nil, errInvalidProposalArgs
167 }
168 return newAncestorCouncilUpdatePropDefinition(a.host, a.target, a.add, a.remove), nil
169}
170
171// subDAOArgs carries CreateSubDAOProposal parameters to the subdao kind.
172// parent is the host DAO the new SubDAO is created under; the executor
173// mutates it (wiring the child in), so the trusted wrapper passes the
174// mutable host handle through args, since New receives only a readonly view.
175type subDAOArgs struct {
176 parent *commondao.CommonDAO
177 name string
178 purpose string
179 description string
180 members []address
181}
182
183// subDAOKind creates proposals that add a SubDAO under the host DAO.
184type subDAOKind struct{}
185
186func (subDAOKind) Name() string { return kindSubDAO }
187
188func (subDAOKind) New(_ commondao.ReadonlyCommonDAO, args any) (commondao.ProposalDefinition, error) {
189 a, ok := args.(subDAOArgs)
190 if !ok {
191 return nil, errInvalidProposalArgs
192 }
193 return newSubDAOPropDefinition(a.parent, a.name, a.purpose, a.description, a.members), nil
194}
195
196// dissolveArgs carries CreateDissolutionProposal parameters to the
197// dissolve kind, including the DAO being dissolved: the proposal is hosted
198// in the nearest live ancestor (the host DAO that Propose passes to New),
199// so the definition must operate on the dissolved descendant carried here,
200// never on the host.
201type dissolveArgs struct {
202 dissolveDAO *commondao.CommonDAO
203 destination address // sweep destination, root DAOs only
204}
205
206// dissolveKind creates proposals that dissolve a DAO or SubDAO.
207type dissolveKind struct{}
208
209func (dissolveKind) Name() string { return kindDissolve }
210
211func (dissolveKind) New(_ commondao.ReadonlyCommonDAO, args any) (commondao.ProposalDefinition, error) {
212 a, ok := args.(dissolveArgs)
213 if !ok {
214 return nil, errInvalidProposalArgs
215 }
216 return newDissolvePropDefinition(a.dissolveDAO, a.destination), nil
217}
218
219// treasurySpendArgs carries CreateTreasurySpendProposal parameters to the
220// treasury-spend kind. dao is the host DAO whose own treasury funds the
221// spend (its sub is the funding source, see FundingDAOID); the trusted
222// wrapper passes the mutable host handle through args, since New receives
223// only a readonly view.
224type treasurySpendArgs struct {
225 dao *commondao.CommonDAO
226 to address
227 coin chain.Coin
228}
229
230// treasurySpendKind creates proposals that send coins from the host DAO's
231// own treasury.
232type treasurySpendKind struct{}
233
234func (treasurySpendKind) Name() string { return kindTreasurySpend }
235
236func (treasurySpendKind) New(_ commondao.ReadonlyCommonDAO, args any) (commondao.ProposalDefinition, error) {
237 a, ok := args.(treasurySpendArgs)
238 if !ok {
239 return nil, errInvalidProposalArgs
240 }
241 return newTreasurySpendPropDefinition(a.dao, a.to, a.coin), nil
242}
243
244// treasuryClawbackArgs carries CreateTreasuryClawbackProposal parameters
245// to the treasury-clawback kind. host is the proposing ancestor DAO (read
246// for the ancestry check); target is the descendant whose treasury the
247// executor sweeps (its sub is the funding source, see FundingDAOID). Both
248// handles come from the trusted wrapper via args, since New receives only
249// a readonly view.
250type treasuryClawbackArgs struct {
251 host *commondao.CommonDAO
252 target *commondao.CommonDAO
253}
254
255// treasuryClawbackKind creates proposals for the host DAO, as an ancestor,
256// to sweep a descendant DAO's treasury to the descendant's parent.
257type treasuryClawbackKind struct{}
258
259func (treasuryClawbackKind) Name() string { return kindTreasuryClawback }
260
261func (treasuryClawbackKind) New(_ commondao.ReadonlyCommonDAO, args any) (commondao.ProposalDefinition, error) {
262 a, ok := args.(treasuryClawbackArgs)
263 if !ok {
264 return nil, errInvalidProposalArgs
265 }
266 return newTreasuryClawbackPropDefinition(a.host, a.target), nil
267}
268
269// treasuryFreezeArgs carries CreateTreasuryFreezeProposal parameters to
270// the treasury-freeze kind. host is the proposing ancestor DAO (read for
271// the ancestry / orphan-rescue check); target is the descendant whose
272// treasury the executor freezes or unfreezes. Both handles come from the
273// trusted wrapper via args, since New receives only a readonly view.
274type treasuryFreezeArgs struct {
275 host *commondao.CommonDAO
276 target *commondao.CommonDAO
277 frozen bool
278}
279
280// treasuryFreezeKind creates proposals for the host DAO, as an ancestor,
281// to freeze or unfreeze a descendant DAO's treasury.
282type treasuryFreezeKind struct{}
283
284func (treasuryFreezeKind) Name() string { return kindTreasuryFreeze }
285
286func (treasuryFreezeKind) New(_ commondao.ReadonlyCommonDAO, args any) (commondao.ProposalDefinition, error) {
287 a, ok := args.(treasuryFreezeArgs)
288 if !ok {
289 return nil, errInvalidProposalArgs
290 }
291 return newTreasuryFreezePropDefinition(a.host, a.target, a.frozen), nil
292}
293
294// errTreasuryFrozen reports an execution proposal blocked because the host
295// DAO's treasury is frozen. It is a sentinel so the freeze gate is greppable
296// and testable (the create-time panic and the Validate-time failure share
297// this one message).
298var errTreasuryFrozen = errors.New("commondao: treasury is frozen")
299
300// executionArgs carries CreateExecutionProposal parameters to the execution
301// kind: a title, a body, and the closure executed on approval. Unlike the
302// governance kinds, the execution kind captures no mutable handle — its only
303// use of the DAO is a freeze-flag read, so its definition holds the readonly
304// host view Propose passes to New rather than a *CommonDAO from args.
305type executionArgs struct {
306 title string
307 body string
308 fn commondao.ExecFunc
309}
310
311// executionKind creates proposals that run an arbitrary ExecFunc as the host
312// DAO's own sub on approval. It is the realm-side counterpart of the /p/
313// commondao.ExecutionKind: identical arbitrary-exec mechanism, but wrapped
314// with the realm's Validable freeze policy (executionPropDefinition.Validate)
315// so a frozen DAO cannot drain its own treasury through it — the /p/ kind
316// carries no such policy. This is the pattern the /p/ extension docs
317// recommend for an arbitrary-exec closure: wrap it with your realm's own
318// checks.
319type executionKind struct{}
320
321func (executionKind) Name() string { return kindExecution }
322
323func (executionKind) New(dao commondao.ReadonlyCommonDAO, args any) (commondao.ProposalDefinition, error) {
324 a, ok := args.(executionArgs)
325 if !ok {
326 return nil, errInvalidProposalArgs
327 }
328 if a.fn == nil {
329 return nil, commondao.ErrExecutionFuncRequired
330 }
331 // The definition captures the readonly host view Propose passed here, not a
332 // handle from args: its only use of the DAO is the Validate freeze read. No
333 // mutable handle means no host-identity pin is needed — the view is the
334 // host by construction.
335 return executionPropDefinition{dao: dao, title: a.title, body: a.body, fn: a.fn}, nil
336}
337
338// executionPropDefinition defines a proposal that runs an arbitrary ExecFunc
339// as the host DAO's own sub on approval. Its Validate blocks execution while
340// the host treasury is frozen, so an execution proposal can never move funds
341// out of a frozen DAO — matching the treasury-spend freeze gate.
342type executionPropDefinition struct {
343 dao commondao.ReadonlyCommonDAO
344 title string
345 body string
346 fn commondao.ExecFunc
347}
348
349// Title returns raw, user-supplied text; the renderer escapes every
350// definition title.
351func (p executionPropDefinition) Title() string { return p.title }
352
353// isTrustedMarkdownBody marks Body as self-assembled: it prepends the
354// realm's own standing warning and escapes the proposer's text itself.
355func (executionPropDefinition) isTrustedMarkdownBody() {}
356
357// Body prefixes the proposer's description with a disclosure. The closure
358// is frozen at Propose, so what executes cannot change after voting starts
359// — but it also cannot be shown: a function value has no rendering, and
360// the title and description are whatever the proposer chose to write. A
361// council voting on this kind is approving code it cannot read, so say so
362// rather than let prose stand alone.
363func (p executionPropDefinition) Body() string {
364 return md.Blockquote("⚠ This proposal runs arbitrary code with the DAO's own authority, "+
365 "including its treasury. The code is fixed when the proposal is created but cannot be "+
366 "displayed here — verify the proposing realm before voting.") +
367 md.Paragraph(md.EscapeText(p.body))
368}
369
370func (executionPropDefinition) VotingPeriod() time.Duration { return time.Hour * 24 * 7 }
371
372// Threshold returns the tally threshold: arbitrary execution runs code under
373// the DAO's authority, so the supermajority default applies.
374func (executionPropDefinition) Threshold() commondao.Threshold {
375 return commondao.ThresholdSupermajority
376}
377
378// Validate runs at proposal creation and again inside Execute: a treasury
379// frozen after the proposal passed still blocks it cleanly (StatusFailed, no
380// funds leave) instead of letting the closure run against a frozen DAO. Freeze
381// = no self-initiated treasury movement, spend and execution alike.
382func (p executionPropDefinition) Validate() error {
383 if p.dao.IsTreasuryFrozen() {
384 return errTreasuryFrozen
385 }
386 return nil
387}
388
389func (p executionPropDefinition) Executor() commondao.ExecFunc {
390 return p.fn
391}
392
393// manageKindsProposal is both the manage-kinds args struct and the proposal
394// definition it produces: manageKindsKind.New validates it and returns it
395// unchanged (one type serves both the args and definition roles). dao is the
396// host DAO whose registry the executor
397// mutates: New receives only a readonly view, so the trusted wrapper passes
398// the mutable host handle through args (captured from its own mustGetDAO).
399//
400// A proposal is one of two shapes, both populated only by a trusted
401// wrapper and both by name (the name resolves against the realm catalog):
402// - register: remove=false, name set;
403// - deregister: remove=true, name set.
404//
405// Registering a foreign kind by value is intentionally not offered here: on
406// this realm such a kind would be inert (no propose path). The by-value
407// capability stays available in /p/ (WithProposalKind / RegisterKind) for a
408// downstream realm that authors its own propose wrapper (see /p/ doc.gno,
409// "Extending commondao in your own realm").
410type manageKindsProposal struct {
411 dao *commondao.CommonDAO
412 remove bool
413 name string
414}
415
416// manageKindsKind creates governance proposals that register or deregister
417// a catalog proposal kind on the host DAO by name — the DAO's one permanent
418// capability to manage which kinds it accepts. Registering adds a kind;
419// deregistering removes one, which blocks new proposals of that kind while
420// in-flight ones still vote and execute. It is decided by supermajority and
421// cannot itself be deregistered (the self-brick guard below), so a DAO
422// always keeps the ability to manage its kind set.
423type manageKindsKind struct{}
424
425func (manageKindsKind) Name() string { return kindManageKinds }
426
427func (manageKindsKind) New(dao commondao.ReadonlyCommonDAO, args any) (commondao.ProposalDefinition, error) {
428 p, ok := args.(manageKindsProposal)
429 if !ok {
430 return nil, errInvalidProposalArgs
431 }
432 // Defense in depth: this kind captures a mutable host handle from args (it
433 // must, to register/deregister on Execute) while the checks below read the
434 // readonly host Propose passed here. Pin them to the same DAO so a future
435 // wrapper can never validate against one registry and mutate another (the
436 // trusted wrapper always passes matching handles today).
437 if p.dao.ID() != dao.ID() {
438 return nil, errInvalidProposalArgs
439 }
440
441 if p.remove {
442 // Deregister by name. Reject no-ops so a council vote is always
443 // about an actual change, and reject the self-brick: manage-kinds
444 // is the only un-deregisterable kind, so a DAO can never lose the
445 // ability to manage its kind set.
446 if !dao.HasKind(p.name) {
447 return nil, errors.New("proposal kind is not registered: " + p.name)
448 }
449 if p.name == kindManageKinds {
450 return nil, errors.New("the manage-kinds kind cannot be deregistered")
451 }
452 return p, nil
453 }
454
455 // Register by name: the name must resolve against the realm catalog, and
456 // reject a no-op (already registered) so a council vote is always about
457 // an actual change.
458 if catalogKind(p.name) == nil {
459 return nil, errors.New("unknown proposal kind")
460 }
461 if dao.HasKind(p.name) {
462 return nil, errors.New("proposal kind is already registered")
463 }
464
465 return p, nil
466}
467
468// Title returns the proposal title as raw text: the renderer escapes
469// every definition title, so escaping the kind name here (unlike in Body,
470// which the renderer trusts as markdown) would double-escape it.
471func (p manageKindsProposal) Title() string {
472 if p.remove {
473 return "Deregister Proposal Kind: " + p.name
474 }
475 return "Register Proposal Kind: " + p.name
476}
477
478func (manageKindsProposal) VotingPeriod() time.Duration { return time.Hour * 24 * 7 }
479
480// isTrustedMarkdownBody marks Body as self-assembled markdown; the embedded
481// kind name is escaped in Body itself (defense in depth).
482func (manageKindsProposal) isTrustedMarkdownBody() {}
483
484// Threshold returns the tally threshold: changing which proposal kinds a
485// DAO accepts alters its governance surface, so the supermajority default
486// applies.
487func (manageKindsProposal) Threshold() commondao.Threshold {
488 return commondao.ThresholdSupermajority
489}
490
491// Validate re-asserts the self-brick at Execute time (Validate reruns
492// inside Execute): the manage-kinds kind can never be deregistered, so a
493// DAO always keeps the ability to manage its kind set. This is a
494// defense-in-depth second layer behind the New check.
495func (p manageKindsProposal) Validate() error {
496 if p.remove && p.name == kindManageKinds {
497 return errors.New("the manage-kinds kind cannot be deregistered")
498 }
499 return nil
500}
501
502func (p manageKindsProposal) Body() string {
503 var b strings.Builder
504
505 // The kind name is validated against the catalog; it is escaped anyway
506 // as defense in depth.
507 b.WriteString(md.Paragraph(md.Bold("Proposal Kind:") + "\n" + md.EscapeText(p.name)))
508
509 action := "registered: new proposals of this kind can be created."
510 if p.remove {
511 action = "deregistered: no new proposal of this kind can be created. " +
512 "In-flight proposals of the kind still vote and execute."
513 }
514 b.WriteString(md.Paragraph(md.Bold("Effect:") + "\nThe proposal kind is " + action))
515
516 return b.String()
517}
518
519func (p manageKindsProposal) Executor() commondao.ExecFunc {
520 return p.execute
521}
522
523// execute registers or deregisters the catalog kind, returning any registry
524// error unchanged so a race between two concurrently passed manage-kinds
525// proposals fails the later one cleanly (StatusFailed) instead of panicking
526// the transaction. catalogKind is a process-global immutable lookup, so a
527// name valid at New still resolves here; the executor does not re-resolve
528// or re-validate beyond the Validate self-brick. It moves no funds, so the
529// definition is not Funded and ignores sub.
530func (p manageKindsProposal) execute(_ int, sub realm) error {
531 if p.remove {
532 return p.dao.DeregisterKind(p.name)
533 }
534 return p.dao.RegisterKind(catalogKind(p.name))
535}