Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}