Search Apps Documentation Source Content File Folder Download Copy Actions Download

governance_propose.gno

12.03 Kb ยท 442 lines
  1package v1
  2
  3import (
  4	"chain"
  5	"chain/runtime"
  6	"time"
  7
  8	"gno.land/p/nt/avl"
  9	"gno.land/r/gnoswap/gns"
 10	"gno.land/r/gnoswap/halt"
 11
 12	"gno.land/r/gnoswap/gov/governance"
 13)
 14
 15// ProposeText creates a text proposal for community discussion.
 16//
 17// Signal proposals for non-binding community sentiment.
 18// Used for policy discussions, roadmap planning, and community feedback.
 19// No on-chain execution, serves as formal governance record.
 20//
 21// Parameters:
 22//   - title: Short, descriptive proposal title (max 100 chars recommended)
 23//   - description: Full proposal content with rationale and context
 24//
 25// Requirements:
 26//   - Caller must hold minimum 1,000 GNS tokens
 27//   - No other active proposal from same address
 28//   - Title and description must be non-empty
 29//
 30// Process:
 31//   - 1 day delay before voting starts
 32//   - 7 days voting period
 33//   - Simple majority decides outcome
 34//   - No execution phase (signal only)
 35//
 36// Returns new proposal ID.
 37func (gv *governanceV1) ProposeText(
 38	title string,
 39	description string,
 40) (newProposalId int64) {
 41	halt.AssertIsNotHaltedGovernance()
 42
 43	callerAddress := runtime.PreviousRealm().Address()
 44
 45	createdAt := time.Now().Unix()
 46	createdHeight := runtime.ChainHeight()
 47	gnsBalance := gns.BalanceOf(callerAddress)
 48
 49	config, ok := gv.getCurrentConfig()
 50	if !ok {
 51		panic(errDataNotFound)
 52	}
 53
 54	// Clean up inactive user proposals before checking if caller already has an active proposal
 55	err := gv.removeInactiveUserProposals(callerAddress, createdAt)
 56	if err != nil {
 57		panic(err)
 58	}
 59
 60	// Check if caller already has an active proposal (one proposal per address)
 61	if gv.hasActiveProposal(callerAddress, createdAt) {
 62		panic(errAlreadyActiveProposal)
 63	}
 64
 65	// Get snapshot time and total voting weight for proposal creation
 66	maxVotingWeight, snapshotTime, err := gv.getVotingWeightSnapshot(
 67		createdAt,
 68		config.VotingWeightSmoothingDuration,
 69	)
 70	if err != nil {
 71		panic(err)
 72	}
 73
 74	// Create the text proposal with metadata
 75	proposal, err := gv.createProposal(
 76		governance.Text,
 77		config,
 78		maxVotingWeight,
 79		snapshotTime,
 80		governance.NewProposalMetadata(title, description),
 81		NewProposalTextData(),
 82		callerAddress,
 83		gnsBalance,
 84		createdAt,
 85		createdHeight,
 86	)
 87	if err != nil {
 88		panic(err)
 89	}
 90
 91	// Initialize empty voting info tree for this proposal (votes will be added as users vote)
 92	err = gv.updateProposalUserVotes(proposal, avl.NewTree())
 93	if err != nil {
 94		panic(err)
 95	}
 96
 97	// Emit proposal creation event for indexing and tracking
 98	previousRealm := runtime.PreviousRealm()
 99	chain.Emit(
100		"ProposeText",
101		"prevAddr", previousRealm.Address().String(),
102		"prevRealm", previousRealm.PkgPath(),
103		"title", title,
104		"description", description,
105		"proposalId", formatInt(proposal.ID()),
106		"quorumAmount", formatInt(proposal.VotingQuorumAmount()),
107		"maxVotingWeight", formatInt(proposal.VotingMaxWeight()),
108		"configVersion", formatInt(proposal.ConfigVersion()),
109		"createdAt", formatInt(proposal.CreatedAt()),
110	)
111
112	return proposal.ID()
113}
114
115// ProposeCommunityPoolSpend creates a treasury disbursement proposal.
116//
117// Allocates community pool funds for approved purposes.
118// Supports grants, development funding, and protocol incentives.
119// Automatic transfer on execution if approved.
120//
121// Parameters:
122//   - title: Proposal title describing purpose
123//   - description: Detailed justification and budget breakdown
124//   - to: Recipient address for funds
125//   - tokenPath: Token contract path (e.g., "gno.land/r/gnoswap/gns")
126//   - amount: Amount to transfer (in smallest unit)
127//
128// Requirements:
129//   - Caller must hold minimum 1,000 GNS tokens
130//   - Sufficient balance in community pool
131//   - Valid recipient address
132//   - Supported token type
133//
134// Security:
135//   - Enforces timelock after approval
136//   - Single transfer per proposal
137//   - Tracks all disbursements on-chain
138//
139// Returns new proposal ID.
140func (gv *governanceV1) ProposeCommunityPoolSpend(
141	title string,
142	description string,
143	to address,
144	tokenPath string,
145	amount int64,
146) (newProposalId int64) {
147	halt.AssertIsNotHaltedGovernance()
148	halt.AssertIsNotHaltedWithdraw()
149
150	assertIsValidToken(tokenPath)
151
152	createdAt := time.Now().Unix()
153	createdHeight := runtime.ChainHeight()
154
155	callerAddress := runtime.PreviousRealm().Address()
156	gnsBalance := gns.BalanceOf(callerAddress)
157
158	config, ok := gv.getCurrentConfig()
159	if !ok {
160		panic(errDataNotFound)
161	}
162
163	// Clean up inactive user proposals before checking if caller already has an active proposal
164	err := gv.removeInactiveUserProposals(callerAddress, createdAt)
165	if err != nil {
166		panic(err)
167	}
168
169	// Check if caller already has an active proposal (one proposal per address)
170	if gv.hasActiveProposal(callerAddress, createdAt) {
171		panic(errAlreadyActiveProposal)
172	}
173
174	// Get snapshot time and total voting weight for proposal creation
175	maxVotingWeight, snapshotTime, err := gv.getVotingWeightSnapshot(
176		createdAt,
177		config.VotingWeightSmoothingDuration,
178	)
179	if err != nil {
180		panic(err)
181	}
182
183	// Create the community pool spend proposal with execution data
184	proposal, err := gv.createProposal(
185		governance.CommunityPoolSpend,
186		config,
187		maxVotingWeight,
188		snapshotTime,
189		governance.NewProposalMetadata(title, description),
190		NewProposalCommunityPoolSpendData(tokenPath, to, amount, COMMUNITY_POOL_PATH),
191		callerAddress,
192		gnsBalance,
193		createdAt,
194		createdHeight,
195	)
196	if err != nil {
197		panic(err)
198	}
199
200	// Initialize empty voting info tree for this proposal (votes will be added as users vote)
201	err = gv.updateProposalUserVotes(proposal, avl.NewTree())
202	if err != nil {
203		panic(err)
204	}
205
206	// Emit proposal creation event for indexing and tracking
207	previousRealm := runtime.PreviousRealm()
208	chain.Emit(
209		"ProposeCommunityPoolSpend",
210		"prevAddr", previousRealm.Address().String(),
211		"prevRealm", previousRealm.PkgPath(),
212		"title", title,
213		"description", description,
214		"to", to.String(),
215		"tokenPath", tokenPath,
216		"amount", formatInt(amount),
217		"proposalId", formatInt(proposal.ID()),
218		"quorumAmount", formatInt(proposal.VotingQuorumAmount()),
219		"maxVotingWeight", formatInt(proposal.VotingMaxWeight()),
220		"configVersion", formatInt(proposal.ConfigVersion()),
221		"createdAt", formatInt(proposal.CreatedAt()),
222	)
223
224	return proposal.ID()
225}
226
227// ProposeParameterChange creates a protocol parameter update proposal.
228//
229// Modifies system parameters through governance.
230// Supports multiple parameter changes in single proposal.
231// Changes apply atomically on execution.
232//
233// Parameters:
234//   - title: Clear description of changes
235//   - description: Rationale and impact analysis
236//   - numToExecute: Number of parameter changes
237//   - executions: JSON array of changes, each containing:
238//   - target: Contract address to modify
239//   - function: Function name to call
240//   - params: Parameters for the function
241//
242// Example executions format:
243//
244//	[{
245//	  "target": "gno.land/r/gnoswap/v1/gov",
246//	  "function": "SetVotingPeriod",
247//	  "params": ["604800"]
248//	}]
249//
250// Requirements:
251//   - Valid JSON format for executions
252//   - Target contracts must exist
253//   - Functions must be governance-callable
254//   - Parameters must match function signatures
255//
256// Returns new proposal ID.
257func (gv *governanceV1) ProposeParameterChange(
258	title string,
259	description string,
260	numToExecute int64,
261	executions string,
262) (newProposalId int64) {
263	halt.AssertIsNotHaltedGovernance()
264
265	callerAddress := runtime.PreviousRealm().Address()
266
267	createdAt := time.Now().Unix()
268	createdHeight := runtime.ChainHeight()
269	gnsBalance := gns.BalanceOf(callerAddress)
270
271	config, ok := gv.getCurrentConfig()
272	if !ok {
273		panic(errDataNotFound)
274	}
275
276	// Clean up inactive user proposals before checking if caller already has an active proposal
277	err := gv.removeInactiveUserProposals(callerAddress, createdAt)
278	if err != nil {
279		panic(err)
280	}
281
282	// Check if caller already has an active proposal (one proposal per address)
283	if gv.hasActiveProposal(callerAddress, createdAt) {
284		panic(errAlreadyActiveProposal)
285	}
286
287	// Get snapshot time and total voting weight for proposal creation
288	maxVotingWeight, snapshotTime, err := gv.getVotingWeightSnapshot(
289		createdAt,
290		config.VotingWeightSmoothingDuration,
291	)
292	if err != nil {
293		panic(err)
294	}
295
296	// Validate executions before creating proposal to fail fast on invalid handlers
297	if err := validateExecutions(numToExecute, executions); err != nil {
298		panic(err)
299	}
300
301	// Create the parameter change proposal with execution data
302	proposal, err := gv.createProposal(
303		governance.ParameterChange,
304		config,
305		maxVotingWeight,
306		snapshotTime,
307		governance.NewProposalMetadata(title, description),
308		NewProposalExecutionData(numToExecute, executions),
309		callerAddress,
310		gnsBalance,
311		createdAt,
312		createdHeight,
313	)
314	if err != nil {
315		panic(err)
316	}
317
318	// Initialize empty voting info tree for this proposal (votes will be added as users vote)
319	err = gv.updateProposalUserVotes(proposal, avl.NewTree())
320	if err != nil {
321		panic(err)
322	}
323
324	// Emit proposal creation event for indexing and tracking
325	previousRealm := runtime.PreviousRealm()
326	chain.Emit(
327		"ProposeParameterChange",
328		"prevAddr", previousRealm.Address().String(),
329		"prevRealm", previousRealm.PkgPath(),
330		"title", title,
331		"description", description,
332		"numToExecute", formatInt(numToExecute),
333		"executions", executions,
334		"proposalId", formatInt(proposal.ID()),
335		"quorumAmount", formatInt(proposal.VotingQuorumAmount()),
336		"maxVotingWeight", formatInt(proposal.VotingMaxWeight()),
337		"configVersion", formatInt(proposal.ConfigVersion()),
338		"createdAt", formatInt(proposal.CreatedAt()),
339	)
340
341	return proposal.ID()
342}
343
344// createProposal handles proposal creation logic.
345// Validates input data, checks proposer eligibility, and creates proposal object.
346func (gv *governanceV1) createProposal(
347	proposalType governance.ProposalType,
348	config governance.Config,
349	maxVotingWeight int64,
350	snapshotTime int64,
351	proposalMetadata *governance.ProposalMetadata,
352	proposalData *governance.ProposalData,
353	proposerAddress address,
354	proposerGnsBalance int64,
355	createdAt int64,
356	createdHeight int64,
357) (*governance.Proposal, error) {
358	// Validate proposal metadata (title and description)
359	metadataResolver := NewProposalMetadataResolver(proposalMetadata)
360	err := metadataResolver.Validate()
361	if err != nil {
362		return nil, err
363	}
364
365	// Validate proposal data (type-specific validation)
366	dataResolver := NewProposalDataResolver(proposalData)
367	err = dataResolver.Validate()
368	if err != nil {
369		return nil, err
370	}
371
372	// Check if proposer has enough GNS balance to create proposal
373	if proposerGnsBalance < config.ProposalCreationThreshold {
374		return nil, errNotEnoughBalance
375	}
376
377	// Generate unique proposal ID
378	proposalID := gv.nextProposalID()
379
380	// Create proposal status with voting schedule and requirements
381	proposalStatus := NewProposalStatus(
382		config,
383		maxVotingWeight,
384		proposalType.IsExecutable(),
385		createdAt,
386	)
387
388	// Get current configuration version for tracking
389	configVersion := gv.getCurrentConfigVersion()
390
391	// Create the proposal object with snapshotTime for lazy voting weight lookup
392	proposal := governance.NewProposal(
393		proposalID,
394		proposalStatus,
395		proposalMetadata,
396		proposalData,
397		proposerAddress,
398		configVersion,
399		snapshotTime,
400		createdAt,
401		createdHeight,
402	)
403
404	// Store the proposal in state
405	success := gv.addProposal(proposal)
406	if !success {
407		return nil, errDataNotFound
408	}
409
410	return proposal, nil
411}
412
413// getVotingWeightSnapshot retrieves the averaged total voting weight for proposal creation.
414// It uses two snapshots (current and current - smoothingPeriod) and averages them.
415func (gv *governanceV1) getVotingWeightSnapshot(
416	current,
417	smoothingPeriod int64,
418) (int64, int64, error) {
419	// Calculate snapshot time by going back by smoothing period
420	snapshotTime := current - smoothingPeriod
421	if snapshotTime < 0 {
422		snapshotTime = 0
423	}
424
425	// Get total delegation amount at snapshot time from staker contract via accessor
426	totalAtSnapshot, ok := gv.stakerAccessor.GetTotalDelegationAmountAtSnapshot(snapshotTime)
427	if !ok {
428		totalAtSnapshot = 0
429	}
430
431	totalAtCurrent, ok := gv.stakerAccessor.GetTotalDelegationAmountAtSnapshot(current)
432	if !ok {
433		totalAtCurrent = 0
434	}
435
436	totalVotingWeight := safeAddInt64(totalAtSnapshot, totalAtCurrent) / 2
437	if totalVotingWeight <= 0 {
438		return 0, snapshotTime, errNotEnoughVotingWeight
439	}
440
441	return totalVotingWeight, snapshotTime, nil
442}