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}