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

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}