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}