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

governance_propose.gno

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