emission.gno
10.12 Kb · 299 lines
1package emission
2
3import (
4 "chain"
5 "chain/runtime"
6 "math"
7 "time"
8
9 gnsmath "gno.land/p/gnoswap/gnsmath/v1"
10 "gno.land/p/gnoswap/utils/v1"
11
12 "gno.land/r/gnoswap/access/v1"
13 "gno.land/r/gnoswap/gns"
14 "gno.land/r/gnoswap/halt/v1"
15)
16
17const (
18 totalDistributionDuration = 12 * 365 * 24 * 60 * 60 // 12 years
19
20 // DefaultInitialPoolTierPath is the canonical default initial pool that must
21 // exist before emission distribution can start. The pool contract registers a
22 // checker (SetDefaultInitialPoolChecker) that verifies this pool exists.
23 DefaultInitialPoolTierPath = "gno.land/r/gnoland/wugnot.wugnot:gno.land/r/gnoswap/gns.GNS:3000"
24)
25
26var (
27 // leftGNSAmount tracks undistributed GNS tokens from previous distributions
28 leftGNSAmount int64
29
30 // lastExecutedTimestamp stores the last timestamp when distribution was executed
31 lastExecutedTimestamp int64
32
33 // emissionAddr is the address of the emission realm
34 emissionAddr address
35
36 // distributionStartTimestamp is the timestamp from which emission distribution starts
37 // Default is 0, meaning distribution is not started until explicitly set
38 distributionStartTimestamp int64
39
40 // onDistributionPctChangeCallback is called when distribution percentages change
41 // This allows external contracts (like staker) to update their caches
42 onDistributionPctChangeCallback func(cur realm, emissionAmountPerSecond int64)
43
44 // defaultInitialPoolChecker verifies the canonical default initial pool exists.
45 // It is registered by pool/v1 at initialization time so that emission does not
46 // need a compile-time dependency on the pool realm.
47 defaultInitialPoolChecker func(poolPath string) bool
48)
49
50func init(cur realm) {
51 emissionAddr = cur.Address()
52}
53
54// setLeftGNSAmount updates the undistributed GNS token amount
55func setLeftGNSAmount(amount int64) {
56 if amount < 0 {
57 panic("left GNS amount cannot be negative")
58 }
59
60 leftGNSAmount = amount
61}
62
63// setLastExecutedTimestamp updates the timestamp of the last emission distribution execution.
64func setLastExecutedTimestamp(timestamp int64) {
65 if timestamp < 0 {
66 panic("last executed timestamp cannot be negative")
67 }
68
69 lastExecutedTimestamp = timestamp
70}
71
72// MintAndDistributeGns mints and distributes GNS tokens according to the emission schedule.
73//
74// This function is called automatically by protocol contracts during user interactions
75// to trigger periodic GNS emission. It mints new tokens based on elapsed time since
76// last distribution and distributes them to predefined targets (staker, devops, etc.).
77//
78// Parameters:
79// - cur: Current realm context; callers use cross(cur) when crossing into this realm.
80//
81// Returns:
82// - distributedAmount: total amount of GNS distributed in this call
83// - success: false only when emission is halted; true when processing completes, even if no tokens are distributed
84//
85// Note: Distribution only occurs if start timestamp is set and reached.
86// Any undistributed tokens from previous calls are carried forward.
87func MintAndDistributeGns(cur realm) (int64, bool) {
88 if halt.IsHaltedEmission() {
89 return 0, false
90 }
91
92 currentHeight := runtime.ChainHeight()
93 currentTimestamp := time.Now().Unix()
94
95 // Check if distribution start timestamp is set and if current timestamp has reached it
96 // If distributionStartTimestamp is 0 (default), skip distribution to prevent immediate start
97 // If current timestamp is below start timestamp, skip distribution
98 if distributionStartTimestamp == 0 || currentTimestamp < distributionStartTimestamp {
99 return 0, true
100 }
101
102 // Skip if we've already minted tokens at this timestamp
103 lastMintedTimestamp := gns.LastMintedTimestamp()
104 if currentTimestamp <= lastMintedTimestamp {
105 return 0, true
106 }
107
108 // Additional check to prevent re-entrancy
109 if lastExecutedTimestamp >= currentTimestamp {
110 // Skip if we've already processed this height in emission
111 return 0, true
112 }
113
114 // Mint new tokens and add any leftover amounts from previous distribution
115 mintedEmissionRewardAmount := gns.MintGns(cross(cur), emissionAddr)
116
117 // Validate minted amount
118 if mintedEmissionRewardAmount < 0 {
119 panic("minted emission reward amount cannot be negative")
120 }
121
122 distributableAmount := mintedEmissionRewardAmount
123 prevLeftAmount := GetLeftGNSAmount()
124
125 if leftGNSAmount > 0 {
126 // Check for overflow before addition
127 if distributableAmount > math.MaxInt64-prevLeftAmount {
128 panic("distributable amount would overflow")
129 }
130
131 distributableAmount += prevLeftAmount
132 setLeftGNSAmount(0)
133 }
134
135 distributable, leftAmount := calculateDistributableAmounts(distributableAmount)
136 totalDistAmount := gnsmath.SafeSubInt64(distributableAmount, leftAmount)
137 if leftAmount > 0 {
138 setLeftGNSAmount(leftAmount)
139 }
140 setLastExecutedTimestamp(currentTimestamp)
141
142 amountByAddress, err := applyDistribution(distributable)
143 if err != nil {
144 panic(err)
145 }
146
147 if err := transferToTarget(0, cur, amountByAddress); err != nil {
148 panic(err)
149 }
150
151 stakerPct, err := GetDistributionBpsPct(LIQUIDITY_STAKER)
152 if err != nil {
153 panic(err)
154 }
155 stakerRewardPerSecond := GetEmissionAmountPerSecondBy(currentTimestamp, stakerPct)
156
157 govStakerPct, err := GetDistributionBpsPct(GOV_STAKER)
158 if err != nil {
159 panic(err)
160 }
161 govStakerRewardPerSecond := GetEmissionAmountPerSecondBy(currentTimestamp, govStakerPct)
162
163 previousRealm := cur.Previous()
164 chain.Emit(
165 "MintAndDistributeGns",
166 "prevAddr", previousRealm.Address().String(),
167 "prevRealm", previousRealm.PkgPath(),
168 "lastTimestamp", utils.FormatInt(lastExecutedTimestamp),
169 "currentTimestamp", utils.FormatInt(currentTimestamp),
170 "currentHeight", utils.FormatInt(currentHeight),
171 "mintedAmount", utils.FormatInt(mintedEmissionRewardAmount),
172 "prevLeftAmount", utils.FormatInt(prevLeftAmount),
173 "distributedAmount", utils.FormatInt(totalDistAmount),
174 "currentLeftAmount", utils.FormatInt(GetLeftGNSAmount()),
175 "gnsTotalSupply", utils.FormatInt(gns.TotalSupply()),
176 "stakerRewardPerSecond", utils.FormatInt(stakerRewardPerSecond),
177 "govStakerRewardPerSecond", utils.FormatInt(govStakerRewardPerSecond),
178 )
179
180 return totalDistAmount, true
181}
182
183// SetDistributionStartTime sets the timestamp when emission distribution starts.
184//
185// This function controls when GNS emission begins. Before the configured
186// timestamp is reached, an admin or governance caller may reschedule it. Once
187// the timestamp is reached, the start time is immutable.
188//
189// Parameters:
190// - cur: Current realm context; callers use cross(cur) when crossing into this realm.
191// - startTimestamp: positive future Unix timestamp when emission should begin.
192//
193// Requirements:
194// - Must be called before distribution starts.
195// - Timestamp must be in the future and leave room for the 12-year schedule.
196//
197// Effects:
198// - Sets global distribution start time.
199// - Initializes GNS emission state if not already started.
200// - Emission begins automatically when timestamp is reached.
201//
202// Only callable by admin or governance.
203func SetDistributionStartTime(cur realm, startTimestamp int64) {
204 halt.AssertIsNotHaltedEmission()
205
206 caller := cur.Previous().Address()
207 access.AssertIsAdminOrGovernance(caller)
208 assertDefaultInitialPoolExists()
209
210 if startTimestamp <= 0 {
211 panic("distribution start timestamp must be positive")
212 }
213
214 if startTimestamp > math.MaxInt64-totalDistributionDuration {
215 panic("distribution end timestamp must be before max int64 timestamp")
216 }
217
218 currentTimestamp := time.Now().Unix()
219
220 // Must be in the future.
221 if startTimestamp <= currentTimestamp {
222 panic("distribution start timestamp must be greater than current timestamp")
223 }
224
225 // Cannot change after distribution started.
226 if distributionStartTimestamp != 0 && distributionStartTimestamp <= currentTimestamp {
227 panic("distribution has already started, cannot change start timestamp")
228 }
229
230 prevStartTimestamp := distributionStartTimestamp
231
232 if gns.MintedEmissionAmount() == 0 {
233 currentHeight := runtime.ChainHeight()
234 gns.InitEmissionState(cross(cur), currentHeight, startTimestamp)
235 }
236
237 distributionStartTimestamp = startTimestamp
238
239 chain.Emit(
240 "SetDistributionStartTime",
241 "caller", caller.String(),
242 "prevStartTimestamp", utils.FormatInt(prevStartTimestamp),
243 "newStartTimestamp", utils.FormatInt(startTimestamp),
244 "height", utils.FormatInt(runtime.ChainHeight()),
245 "timestamp", utils.FormatInt(time.Now().Unix()),
246 )
247}
248
249// SetOnDistributionPctChangeCallback registers the optional callback invoked when
250// distribution percentages change. A nil callback clears the registration.
251// This allows external contracts (like staker) to update their internal caches
252// when governance or admin changes emission rates.
253//
254// Parameters:
255// - cur: Current realm context; callers use cross(cur) when crossing into this realm.
256// - callback: nil to clear the callback, or a function receiving its callback
257// realm context and the current staker GNS emission rate per second; a nonnil
258// callback is invoked immediately after registration.
259func SetOnDistributionPctChangeCallback(cur realm, callback func(cur realm, emissionAmountPerSecond int64)) {
260 caller := cur.Previous().Address()
261 access.AssertIsStaker(caller)
262
263 if callback == nil {
264 onDistributionPctChangeCallback = nil
265 return
266 }
267
268 emissionAmountPerSecond, err := GetStakerEmissionAmountPerSecond()
269 if err != nil {
270 panic(err)
271 }
272
273 onDistributionPctChangeCallback = callback
274 onDistributionPctChangeCallback(cross(cur), emissionAmountPerSecond)
275}
276
277// SetDefaultInitialPoolChecker registers the callback that verifies the
278// canonical default initial pool exists before emission starts.
279//
280// The checker receives the pool path as a parameter so emission owns the
281// canonical path policy (DefaultInitialPoolTierPath) while pool supplies only
282// the generic "does this pool exist" capability.
283//
284// Parameters:
285// - cur: Current realm context; callers use cross(cur) when crossing into this realm.
286// - checker: nonnil function returning true when the supplied pool path exists;
287// it is called with DefaultInitialPoolTierPath and nil causes a panic.
288//
289// Only callable by the pool contract.
290func SetDefaultInitialPoolChecker(cur realm, checker func(poolPath string) bool) {
291 caller := cur.Previous().Address()
292 access.AssertIsPool(caller)
293
294 if checker == nil {
295 panic(makeErrorWithDetails(errInvalidEmissionStart, "default initial pool checker cannot be nil"))
296 }
297
298 defaultInitialPoolChecker = checker
299}