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

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}