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

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