public_proposals.gno
15.51 Kb · 421 lines
1package commondao
2
3import (
4 "chain"
5 "strings"
6 "time"
7
8 "gno.land/p/nt/bylaws/v0"
9 "gno.land/p/nt/commondao/v0"
10)
11
12// CreateTextProposal creates a new general text proposal.
13//
14// Parameters:
15// - daoID: ID of the DAO (required)
16// - title: Title of the proposal (required)
17// - body: Body of the proposal (required)
18// - votingDays: The number of days where proposal accepts votes.
19//
20// The default voting period is 7 days.
21func CreateTextProposal(cur realm, daoID uint64, title, body string, votingDays uint8) uint64 {
22 assertCurrent(cur)
23
24 dao := mustGetDAO(daoID)
25 if votingDays > 30 {
26 panic("maximum proposal voting period is 30 days")
27 }
28
29 caller := cur.Previous().Address()
30 assertCallerIsCouncilMember(caller, dao)
31
32 var votingPeriod time.Duration
33 if votingDays == 0 {
34 votingPeriod = time.Hour * 24 * 7
35 } else {
36 votingPeriod = time.Hour * 24 * time.Duration(votingDays)
37 }
38
39 return mustPropose(dao, caller, kindText, textArgs{title, body, votingPeriod})
40}
41
42// CreateCouncilUpdateProposal creates a new proposal to add and/or remove
43// council members.
44//
45// Parameters:
46// - daoID: ID of the DAO (required)
47// - newMembers: Newline separated list of addresses to add to the council
48// - removeMembers: Newline separated list of council addresses to remove
49func CreateCouncilUpdateProposal(cur realm, daoID uint64, newMembers, removeMembers string) uint64 {
50 assertCurrent(cur)
51
52 dao := mustGetDAO(daoID)
53
54 caller := cur.Previous().Address()
55 assertCallerIsCouncilMember(caller, dao)
56
57 args := councilUpdateArgs{dao, parseAddresses(newMembers), parseAddresses(removeMembers)}
58 return mustPropose(dao, caller, kindCouncilUpdate, args)
59}
60
61// CreateAncestorCouncilUpdateProposal creates a proposal for an ancestor
62// DAO to add and/or remove members of a descendant's council
63// (docs/CONSTITUTION.md :1531-1532) — the rescue path for a stuck or
64// empty descendant council. It is hosted and voted in the ancestor
65// (daoID) and decided by supermajority; the proposing DAO must be a
66// proper ancestor of the target, verified at proposal validation.
67//
68// Parameters:
69// - daoID: ID of the proposing ancestor DAO (required)
70// - targetID: ID of the descendant DAO whose council changes (required)
71// - newMembers: Newline separated list of addresses to add to the council
72// - removeMembers: Newline separated list of council addresses to remove
73func CreateAncestorCouncilUpdateProposal(cur realm, daoID, targetID uint64, newMembers, removeMembers string) uint64 {
74 assertCurrent(cur)
75
76 dao := mustGetDAO(daoID)
77
78 caller := cur.Previous().Address()
79 assertCallerIsCouncilMember(caller, dao)
80
81 target := mustGetDAO(targetID)
82 args := ancestorCouncilUpdateArgs{dao, target, parseAddresses(newMembers), parseAddresses(removeMembers)}
83 return mustPropose(dao, caller, kindAncestorCouncilUpdate, args)
84}
85
86// CreateSubDAOProposal creates a new proposal to create a new SubDAO.
87//
88// Parameters:
89// - daoID: ID of the parent DAO (required)
90// - name: A name for the SubDAO (required)
91// - purpose: A purpose for the SubDAO (required)
92// - description: A description for the SubDAO
93// - members: Newline separated list of initial SubDAO council addresses (required)
94func CreateSubDAOProposal(cur realm, daoID uint64, name, purpose, description, members string) uint64 {
95 assertCurrent(cur)
96
97 dao := mustGetDAO(daoID)
98 caller := cur.Previous().Address()
99 assertCallerIsCouncilMember(caller, dao)
100
101 args := subDAOArgs{dao, name, purpose, description, parseAddresses(members)}
102 return mustPropose(dao, caller, kindSubDAO, args)
103}
104
105// CreateDissolutionProposal creates a new proposal to dissolve a DAO or SubDAO.
106//
107// SubDAOs can only be dissolved by the parent DAO, which owns and
108// controls its sub-DAOs (docs/CONSTITUTION.md :1507). When the parent is
109// itself already dissolved, the proposal is hosted in the nearest
110// non-dissolved ancestor, so orphans below a dissolved middle DAO remain
111// dissolvable.
112//
113// Dissolution sweeps any remaining treasury balance. A sub-DAO's sweep
114// goes to its parent and destination must be empty; a root DAO has no
115// parent, so a valid destination address is required.
116//
117// Parameters:
118// - daoID: ID of the DAO to dissolve (required)
119// - destination: sweep destination, root DAOs only
120func CreateDissolutionProposal(cur realm, daoID uint64, destination address) uint64 {
121 assertCurrent(cur)
122
123 // When DAO to dissolve is a SubDAO make sure that proposal is created
124 // in the parent DAO, or in the nearest non-dissolved ancestor when
125 // parents were dissolved first.
126 dao := mustGetDAO(daoID)
127 dissolveDAO := dao
128 if parent := dao.Parent(); parent != nil {
129 for parent.IsDeleted() && parent.Parent() != nil {
130 parent = parent.Parent()
131 }
132 dao = parent
133 }
134
135 caller := cur.Previous().Address()
136 assertCallerIsCouncilMember(caller, dao)
137
138 // The host (nearest live ancestor) gates and votes the proposal, while
139 // the definition operates on the dissolved descendant carried in args.
140 return mustPropose(dao, caller, kindDissolve, dissolveArgs{dissolveDAO, destination})
141}
142
143// CreateTreasurySpendProposal creates a new proposal to send coins from
144// the DAO's own treasury (docs/CONSTITUTION.md :1542-1543).
145//
146// Parameters:
147// - daoID: ID of the DAO whose treasury is spent (required)
148// - to: recipient address (required)
149// - denom: coin denomination, e.g. "ugnot" (required)
150// - amount: coin amount, must be positive (required)
151func CreateTreasurySpendProposal(cur realm, daoID uint64, to address, denom string, amount int64) uint64 {
152 assertCurrent(cur)
153
154 dao := mustGetDAO(daoID)
155 caller := cur.Previous().Address()
156 assertCallerIsCouncilMember(caller, dao)
157
158 args := treasurySpendArgs{dao, to, chain.NewCoin(denom, amount)}
159 return mustPropose(dao, caller, kindTreasurySpend, args)
160}
161
162// CreateTreasuryClawbackProposal creates a new proposal for an ancestor
163// DAO to sweep a descendant DAO's full treasury balance to the target's
164// parent (docs/CONSTITUTION.md :1507). The proposing DAO must be a
165// proper ancestor of the target; a target's own options can never block
166// an ancestor's clawback.
167//
168// Parameters:
169// - daoID: ID of the proposing ancestor DAO (required)
170// - targetID: ID of the descendant DAO to claw back (required)
171func CreateTreasuryClawbackProposal(cur realm, daoID, targetID uint64) uint64 {
172 assertCurrent(cur)
173
174 dao := mustGetDAO(daoID)
175 caller := cur.Previous().Address()
176 assertCallerIsCouncilMember(caller, dao)
177
178 target := mustGetDAO(targetID)
179 return mustPropose(dao, caller, kindTreasuryClawback, treasuryClawbackArgs{dao, target})
180}
181
182// CreateTreasuryFreezeProposal creates a new proposal for an ancestor DAO
183// to freeze or unfreeze a descendant DAO's treasury. While frozen, no
184// treasury spend can execute. Only a proper ancestor can unfreeze — the
185// frozen DAO's own council cannot.
186//
187// Parameters:
188// - daoID: ID of the proposing ancestor DAO (required)
189// - targetID: ID of the descendant DAO to freeze or unfreeze (required)
190// - frozen: true to freeze the target's treasury, false to unfreeze
191func CreateTreasuryFreezeProposal(cur realm, daoID, targetID uint64, frozen bool) uint64 {
192 assertCurrent(cur)
193
194 dao := mustGetDAO(daoID)
195 caller := cur.Previous().Address()
196 assertCallerIsCouncilMember(caller, dao)
197
198 target := mustGetDAO(targetID)
199 return mustPropose(dao, caller, kindTreasuryFreeze, treasuryFreezeArgs{dao, target, frozen})
200}
201
202// CreateExecutionProposal creates a proposal that runs an arbitrary
203// ExecFunc as the DAO's own sub on approval, through the realm's execution
204// kind.
205//
206// The execution kind is opt-in: it is not seeded on new DAOs and must be
207// registered first through a supermajority CreateRegisterKindProposal.
208//
209// Freeze policy: an execution proposal moves value under the DAO's own
210// authority, so it is subject to the treasury freeze exactly like a spend.
211// This wrapper fails fast when the treasury is already frozen, and the
212// definition re-checks at Execute (so a freeze landing after the proposal
213// passed fails it cleanly, StatusFailed, no funds leaving). An ancestor's
214// clawback/dissolution is a separate power and is not blocked by freeze.
215//
216// Sharp edges (known limitations):
217// - The fn closure cannot be encoded in a CLI transaction, so this wrapper
218// is reachable only from a PERSISTENT realm that imports this one and is
219// a council member of the DAO (a realm-in-council). The closure must be
220// authored in that realm so it survives Propose→Execute; a `maketx run`
221// script's closure does not persist and cannot execute later.
222// - A closure that panics or runs out of gas aborts the whole Execute tx, so
223// the proposal is stuck Active (every retry re-aborts) and can never
224// finalize. The only recovery is dissolving the DAO (Dissolve dismisses
225// in-flight proposals). Author closures that return an error instead of
226// panicking so a bad execution fails cleanly (StatusFailed) and releases.
227//
228// Parameters:
229// - daoID: ID of the DAO (required)
230// - title: proposal title (raw text, escaped when rendered)
231// - body: proposal body (raw text, escaped when rendered)
232// - fn: the closure executed on approval (required, non-nil)
233func CreateExecutionProposal(cur realm, daoID uint64, title, body string, fn commondao.ExecFunc) uint64 {
234 assertCurrent(cur)
235
236 dao := mustGetDAO(daoID)
237 caller := cur.Previous().Address()
238 assertCallerIsCouncilMember(caller, dao)
239
240 // Opt-in gate: refuse unless the DAO registered the execution kind.
241 // Without this, arbitrary code execution would ride on a name a DAO
242 // never opted into. Propose also rejects an unregistered kind, but this
243 // fails fast with a clear message before any definition is built.
244 assertKindRegistered(dao, kindExecution)
245
246 // Freeze gate (intentional defense in depth): a frozen DAO cannot initiate
247 // any treasury movement, so refuse up front here even though the
248 // definition's Validate re-checks the same flag at create and at execute.
249 // Redundant on purpose — two independent layers on the "no funds leave a
250 // frozen DAO" invariant. Without it an execution proposal could drain a
251 // frozen DAO's own treasury, defeating an ancestor's freeze.
252 if dao.IsTreasuryFrozen() {
253 panic(errTreasuryFrozen)
254 }
255
256 return mustPropose(dao, caller, kindExecution, executionArgs{title: title, body: body, fn: fn})
257}
258
259// CreateRegisterKindProposal creates a proposal to register one of the
260// realm's catalog proposal kinds on a DAO by name, through the permanent
261// manage-kinds kind (e.g. register "execution").
262//
263// The proposal is hosted and voted in the DAO itself and decided by
264// supermajority; on approval the named catalog kind is registered, so new
265// proposals of that kind can be created. The manage-kinds kind is seeded
266// on every DAO, so this path is always available.
267//
268// Parameters:
269// - daoID: ID of the DAO (required)
270// - kindName: name of the catalog proposal kind to register (required)
271func CreateRegisterKindProposal(cur realm, daoID uint64, kindName string) uint64 {
272 assertCurrent(cur)
273
274 dao := mustGetDAO(daoID)
275 caller := cur.Previous().Address()
276 assertCallerIsCouncilMember(caller, dao)
277
278 return mustPropose(dao, caller, kindManageKinds, manageKindsProposal{dao: dao, name: kindName})
279}
280
281// CreateDeregisterKindProposal creates a proposal to deregister a proposal
282// kind from a DAO by name, through the permanent manage-kinds kind.
283//
284// The proposal is hosted and voted in the DAO itself and decided by
285// supermajority; on approval the kind is deregistered, which blocks new
286// proposals of that kind while in-flight ones still vote and execute. The
287// manage-kinds kind itself cannot be deregistered, so a DAO always keeps
288// the ability to manage its kind set (and to re-register a catalog kind by
289// name).
290//
291// Parameters:
292// - daoID: ID of the DAO (required)
293// - kindName: name of the proposal kind to deregister (required)
294func CreateDeregisterKindProposal(cur realm, daoID uint64, kindName string) uint64 {
295 assertCurrent(cur)
296
297 dao := mustGetDAO(daoID)
298 caller := cur.Previous().Address()
299 assertCallerIsCouncilMember(caller, dao)
300
301 return mustPropose(dao, caller, kindManageKinds, manageKindsProposal{dao: dao, remove: true, name: kindName})
302}
303
304// CreateAmendBylawsProposal creates a proposal to add, amend or remove one
305// of the DAO's bylaws documents with a verifiable diff patch (see
306// gno.land/p/nt/bylaws/v0). The mandates/ folder is reserved: the
307// Constitution grants a council self-power over its Bylaws only, so
308// mandates change from above (creation or an ancestor's amendment — not
309// implemented yet), never through this proposal.
310// The payload is an encoded patch — build it with
311// AmendBylawsPayload (e.g. through a vm/qeval query) or with
312// bylaws.Diff(...).Encode() from a realm. The patch pins the sha256 of the
313// document text it was diffed against, so an amendment racing a concurrent
314// change to the same document fails cleanly instead of clobbering it; a
315// patch already stale at creation is rejected here. Amendments are decided
316// by supermajority — the default council rule; the Constitution names no
317// special threshold for a council amending its own documents.
318//
319// Parameters:
320// - daoID: ID of the DAO (required)
321// - payload: encoded bylaws patch (required)
322func CreateAmendBylawsProposal(cur realm, daoID uint64, payload string) uint64 {
323 assertCurrent(cur)
324
325 dao := mustGetDAO(daoID)
326 caller := cur.Previous().Address()
327 assertCallerIsCouncilMember(caller, dao)
328
329 patch, err := bylaws.DecodePatch(payload)
330 if err != nil {
331 panic(err)
332 }
333
334 return mustPropose(dao, caller, kindAmendBylaws, amendBylawsProposal{
335 daoID: daoID,
336 set: bylawsOf(daoID),
337 patch: patch,
338 })
339}
340
341// AmendBylawsPayload builds the CreateAmendBylawsProposal payload that
342// changes a DAO's document at path to the proposed text: a new path adds a
343// document, empty proposed text removes one. It diffs against the
344// document's current text and pins its hash, so build the payload fresh
345// (e.g. through a vm/qeval query) and propose promptly — a payload built
346// against superseded text is rejected. Read-only.
347func AmendBylawsPayload(daoID uint64, path, proposed string) string {
348 mustGetDAO(daoID)
349
350 var (
351 cur string
352 exists bool
353 )
354 if set := bylawsView(daoID); set != nil {
355 cur, exists = set.Get(path)
356 }
357
358 p, err := bylaws.DiffTexts(path, cur, proposed, exists)
359 if err != nil {
360 panic(err)
361 }
362 return p.Encode()
363}
364
365// assertKindRegistered panics unless a proposal kind is registered on the
366// DAO. Used to gate the opt-in propose paths so they work only after the DAO
367// registered the kind through governance.
368func assertKindRegistered(dao *commondao.CommonDAO, name string) {
369 if !dao.HasKind(name) {
370 panic("proposal kind is not registered: " + name)
371 }
372}
373
374// mustPropose submits a proposal through one of the DAO's registered
375// proposal kinds and validates it for the current state, panicking on any
376// error. Validation also reruns inside Execute, so this only fails fast at
377// creation.
378func mustPropose(dao *commondao.CommonDAO, caller address, kind string, args any) uint64 {
379 p, err := dao.Propose(caller, kind, args)
380 if err != nil {
381 panic(err)
382 }
383
384 if err = p.Validate(); err != nil {
385 panic(err)
386 }
387
388 return p.ID()
389}
390
391// parseAddresses parses a newline separated list of addresses,
392// deduplicated, panicking on invalid entries.
393func parseAddresses(s string) []address {
394 var addrs []address
395 for _, raw := range strings.Split(s, "\n") {
396 raw = strings.TrimSpace(raw)
397 if raw == "" {
398 continue
399 }
400
401 addr := address(raw)
402 if !addr.IsValid() {
403 panic("invalid address: " + addr.String())
404 }
405
406 if !containsAddress(addrs, addr) {
407 addrs = append(addrs, addr)
408 }
409 }
410 return addrs
411}
412
413// containsAddress checks if an address is present in a list.
414func containsAddress(addrs []address, addr address) bool {
415 for _, a := range addrs {
416 if a == addr {
417 return true
418 }
419 }
420 return false
421}