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}