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