Search Apps Documentation Source Content File Folder Download Copy Actions Download

governance_propose.gno

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