reward_calculation_pool_tier.gno
13.29 Kb · 437 lines
1package staker
2
3import (
4 "errors"
5
6 "gno.land/p/gnoswap/gnsmath"
7 bptree "gno.land/p/nt/bptree/v0"
8
9 sr "gno.land/r/gnoswap/staker"
10)
11
12const (
13 AllTierCount = 4 // 0, 1, 2, 3
14 Tier1 = 1
15 Tier2 = 2
16 Tier3 = 3
17)
18
19// TierRatioFromCounts calculates the ratio distribution for each tier based on pool counts.
20//
21// Parameters:
22// - tier1Count (uint64): Number of pools in tier 1.
23// - tier2Count (uint64): Number of pools in tier 2.
24// - tier3Count (uint64): Number of pools in tier 3.
25//
26// Returns:
27// - TierRatio: The ratio distribution across tier 1, 2, and 3, scaled up by 100.
28func TierRatioFromCounts(tier1Count, tier2Count, tier3Count uint64) sr.TierRatio {
29 // tier1 always exists.
30 //
31 // TierRatio is declared in /r/gnoswap/staker; constructing it via a
32 // composite literal here (/r/gnoswap/staker/v1) trips the construction-time
33 // check ("cannot allocate ... in realm ..."). Route through the domain
34 // constructor sr.NewTierRatio so allocation happens in the declaring realm.
35 if tier2Count == 0 && tier3Count == 0 {
36 return sr.NewTierRatio(100, 0, 0)
37 }
38 if tier2Count == 0 {
39 return sr.NewTierRatio(80, 0, 20)
40 }
41 if tier3Count == 0 {
42 return sr.NewTierRatio(70, 30, 0)
43 }
44 return sr.NewTierRatio(50, 30, 20)
45}
46
47// PoolTier manages pool counts, ratios, and rewards for different tiers.
48//
49// Fields:
50// - membership: Tracks which tier a pool belongs to (poolPath -> blockNumber -> tier).
51//
52// Methods:
53// - CurrentCount: Returns the current count of pools in a tier at a specific timestamp.
54// - CurrentRatio: Returns the current ratio for a tier at a specific timestamp.
55// - CurrentTier: Returns the tier of a specific pool at a given timestamp.
56// - CurrentReward: Retrieves the reward for a tier at a specific timestamp.
57// - changeTier: Updates the tier of a pool and recalculates ratios.
58type PoolTier struct {
59 membership *bptree.BPTree // poolPath -> tier(1, 2, 3)
60
61 tierRatio sr.TierRatio
62
63 counts [AllTierCount]uint64
64
65 lastRewardCacheTimestamp int64
66
67 currentEmission int64
68
69 // returns current emission.
70 getEmission func() int64
71 // Returns a list of halving timestamps and their emission amounts within the interval [start, end) in ascending order.
72 // The first return value is a list of timestamps where halving occurs.
73 // The second return value is a list of emission amounts corresponding to each halving timestamp.
74 getHalvingBlocksInRange func(start, end int64) ([]int64, []int64)
75}
76
77// NewPoolTier creates a new PoolTier instance with single initial 1 tier pool.
78//
79// Parameters:
80// - pools: The pool collection.
81// - currentTime: The current block time.
82// - initialPoolPath: The path of the initial pool.
83// - getEmission: A function that returns the current emission to the staker contract.
84// - getHalvingBlocksInRange: A function that returns a list of halving blocks within the interval [start, end) in ascending order.
85//
86// Returns:
87// - *PoolTier: The new PoolTier instance.
88func NewPoolTier(pools *Pools, currentTime int64, initialPoolPath string, getEmission func() int64, getHalvingBlocksInRange func(start, end int64) ([]int64, []int64)) *PoolTier {
89 result := &PoolTier{
90 membership: sr.NewBPTreeN(16),
91 tierRatio: TierRatioFromCounts(1, 0, 0),
92 lastRewardCacheTimestamp: gnsmath.SafeAddInt64(currentTime, 1),
93 getEmission: getEmission,
94 getHalvingBlocksInRange: getHalvingBlocksInRange,
95 currentEmission: getEmission(),
96 }
97
98 pools.set(initialPoolPath, sr.NewPool(initialPoolPath, currentTime+1))
99 result.changeTier(currentTime+1, pools, initialPoolPath, 1)
100 return result
101}
102
103func NewPoolTierBy(
104 membership *bptree.BPTree,
105 tierRatio sr.TierRatio,
106 counts [AllTierCount]uint64,
107 lastRewardCacheTimestamp int64,
108 currentEmission int64,
109 getEmission func() int64,
110 getHalvingBlocksInRange func(start, end int64) ([]int64, []int64),
111) *PoolTier {
112 return &PoolTier{
113 membership: membership,
114 tierRatio: tierRatio,
115 counts: counts,
116 lastRewardCacheTimestamp: lastRewardCacheTimestamp,
117 getEmission: getEmission,
118 getHalvingBlocksInRange: getHalvingBlocksInRange,
119 currentEmission: currentEmission,
120 }
121}
122
123// CurrentReward returns the current per-pool reward for the given tier.
124func (self *PoolTier) CurrentReward(tier uint64) int64 {
125 currentEmission := self.getEmission()
126 tierRatio, err := self.tierRatio.Get(tier)
127 if err != nil {
128 panic(makeErrorWithDetails(errInvalidPoolTier, err.Error()))
129 }
130
131 tierRatioInt64 := int64(tierRatio)
132 count := int64(self.CurrentCount(tier))
133
134 return calculatePoolReward(currentEmission, tierRatioInt64, count)
135}
136
137// CurrentCount returns the current count of pools in the given tier.
138func (self *PoolTier) CurrentCount(tier uint64) int {
139 if tier >= AllTierCount {
140 return 0
141 }
142 return int(self.counts[tier])
143}
144
145// CurrentAllTierCounts returns the current count of pools in each tier.
146func (self *PoolTier) CurrentAllTierCounts() []uint64 {
147 out := make([]uint64, AllTierCount)
148 copy(out, self.counts[:])
149 return out // returning snapshot
150}
151
152// CurrentTier returns the tier of the given pool.
153func (self *PoolTier) CurrentTier(poolPath string) (tier uint64) {
154 if tierI := self.membership.Get(poolPath); tierI == nil {
155 return 0
156 } else {
157 var ok bool
158 tier, ok = tierI.(uint64)
159 if !ok {
160 panic("failed to cast tier to uint64")
161 }
162 return tier
163 }
164}
165
166// changeTier updates the tier of a pool, recalculates ratios, and applies
167// updated per-pool reward to each of the pools.
168func (self *PoolTier) changeTier(currentTime int64, pools *Pools, poolPath string, nextTier uint64) map[uint64]int64 {
169 self.cacheReward(currentTime, pools)
170 // same as prev. no need to update
171 currentTier := self.CurrentTier(poolPath)
172 if currentTier == nextTier {
173 // no change, return
174 return make(map[uint64]int64)
175 }
176
177 // decrement count from current tier if it exists
178 if currentTier > 0 {
179 if self.counts[currentTier] == 0 {
180 panic("counts underflow: removing from empty tier")
181 }
182 self.counts[currentTier]--
183 }
184
185 if nextTier == 0 {
186 // removed from the tier
187 self.membership.Remove(poolPath)
188 pool, ok := pools.Get(poolPath)
189 if !ok {
190 panic("changeTier: pool not found")
191 }
192 poolResolver := NewPoolResolver(pool)
193 // prevent new rewards from accumulating after tier removal
194 poolResolver.cacheReward(currentTime, 0)
195 } else {
196 // handle all move/add operations
197 self.membership.Set(poolPath, nextTier)
198 self.counts[nextTier]++
199 }
200
201 self.tierRatio = TierRatioFromCounts(self.counts[Tier1], self.counts[Tier2], self.counts[Tier3])
202 currentEmission := self.getEmission()
203 tierRewards := self.computeTierRewards(currentEmission)
204
205 // Cache updated reward for each tiered pool
206 self.membership.Iterate("", "", func(key string, value any) bool {
207 pool, ok := pools.Get(key)
208 if !ok {
209 panic("changeTier: pool not found")
210 }
211 tier, ok := value.(uint64)
212 if !ok {
213 panic("failed to cast value to uint64")
214 }
215
216 poolReward, ok := tierRewards[tier]
217 if !ok {
218 return false // Skip if no pools in tier
219 }
220
221 poolResolver := NewPoolResolver(pool)
222 poolResolver.cacheReward(currentTime, poolReward)
223 return false
224 })
225
226 self.currentEmission = currentEmission
227
228 return tierRewards
229}
230
231// cacheReward MUST be called before calculating any position reward.
232// cacheReward updates the reward cache for each pool, accounting for any halving events
233// that occurred between the last cached timestamp and the current timestamp.
234// Note: Block height is used only for event tracking purposes.
235func (self *PoolTier) cacheReward(currentTimestamp int64, pools *Pools) {
236 lastTimestamp := self.lastRewardCacheTimestamp
237
238 if currentTimestamp <= lastTimestamp {
239 // no need to check
240 return
241 }
242
243 // find halving blocks in range
244 halvingTimestamps, halvingEmissions := self.getHalvingBlocksInRange(lastTimestamp, currentTimestamp)
245
246 if len(halvingTimestamps) == 0 {
247 self.applyCacheToAllPools(pools, currentTimestamp, self.currentEmission)
248 self.lastRewardCacheTimestamp = currentTimestamp
249 return
250 }
251
252 for i, hvTimestamp := range halvingTimestamps {
253 emission := halvingEmissions[i]
254 // caching: [lastTimestamp, hvTimestamp)
255 self.applyCacheToAllPools(pools, hvTimestamp, emission)
256
257 // halve emissions when halvingBlock is reached
258 self.currentEmission = emission
259 }
260
261 // remaining range [lastTimestamp, currentTimestamp)
262 self.applyCacheToAllPools(pools, currentTimestamp, self.currentEmission)
263
264 self.lastRewardCacheTimestamp = currentTimestamp
265}
266
267// cacheRewardForPool caches internal reward/accumulators for a single pool only.
268// This avoids iterating all tiered pools on every position reward calculation.
269func (self *PoolTier) cacheRewardForPool(currentTimestamp int64, pools *Pools, poolPath string) {
270 pool, ok := pools.Get(poolPath)
271 if !ok {
272 return
273 }
274
275 tierNum := self.CurrentTier(poolPath)
276 // Pool not in the internal-incentive system.
277 if tierNum == 0 {
278 return
279 }
280
281 // Find the latest reward cache timestamp for this pool.
282 lastTimestamp := int64(0)
283 hasLast := false
284 pool.RewardCache().ReverseIterate(0, currentTimestamp, func(key int64, _ any) bool {
285 lastTimestamp = key
286 hasLast = true
287 return true
288 })
289
290 if !hasLast {
291 // Fallback to global tier cache cursor.
292 lastTimestamp = self.lastRewardCacheTimestamp
293 }
294
295 if currentTimestamp <= lastTimestamp {
296 return
297 }
298
299 // Determine halving boundaries since the pool's last cached reward timestamp.
300 halvingTimestamps, halvingEmissions := self.getHalvingBlocksInRange(lastTimestamp, currentTimestamp)
301 poolResolver := NewPoolResolver(pool)
302
303 if len(halvingTimestamps) == 0 {
304 // No emission change within the range => use current emission.
305 self.applyCacheToPool(poolResolver, tierNum, currentTimestamp, self.currentEmission)
306 return
307 }
308
309 // Apply caching at every halving boundary.
310 currentEmission := int64(0)
311 for i, hvTimestamp := range halvingTimestamps {
312 currentEmission = halvingEmissions[i]
313 self.applyCacheToPool(poolResolver, tierNum, hvTimestamp, currentEmission)
314 }
315
316 // Remaining range [lastTimestamp, currentTimestamp).
317 self.applyCacheToPool(poolResolver, tierNum, currentTimestamp, currentEmission)
318}
319
320// applyCacheToPool applies the cached reward to all tiered pool.
321func (self *PoolTier) applyCacheToPool(poolResolver *PoolResolver, tierNum uint64, currentTimestamp, emissionInThisInterval int64) {
322 tierRewards := self.computeTierRewards(emissionInThisInterval)
323 poolReward, ok := tierRewards[tierNum]
324 if !ok {
325 return
326 }
327
328 poolResolver.cacheInternalReward(currentTimestamp, poolReward)
329}
330
331// applyCacheToAllPools applies the cached reward to all tiered pools.
332func (self *PoolTier) applyCacheToAllPools(pools *Pools, currentTimestamp, emissionInThisInterval int64) {
333 // calculate denominator and number of pools in each tier
334 counts := self.CurrentAllTierCounts()
335 tierRewards := self.computeTierRewards(emissionInThisInterval)
336
337 // apply cache to all pools
338 self.membership.Iterate("", "", func(key string, value any) bool {
339 pool, ok := pools.Get(key)
340 if !ok {
341 return false
342 }
343
344 tierNum, ok := value.(uint64)
345 if !ok {
346 panic("failed to cast value to uint64")
347 }
348 // Skip pools with tier 0 (removed from tier system)
349 if tierNum == 0 {
350 return false
351 }
352
353 if counts[tierNum] == 0 {
354 return false // Skip if no pools in tier
355 }
356
357 poolReward, ok := tierRewards[tierNum]
358 if !ok {
359 return false
360 }
361
362 // accumulate the reward for the interval (startBlock to endBlock) in the Pool
363 poolResolver := NewPoolResolver(pool)
364 poolResolver.cacheInternalReward(currentTimestamp, poolReward)
365 return false
366 })
367}
368
369// IsInternallyIncentivizedPool returns true if the pool is in a tier.
370func (self *PoolTier) IsInternallyIncentivizedPool(poolPath string) bool {
371 return self.CurrentTier(poolPath) > 0
372}
373
374func (self *PoolTier) CurrentRewardPerPool(poolPath string) int64 {
375 tierNum := self.CurrentTier(poolPath)
376 if tierNum == 0 {
377 return 0 // Pool not in any tier
378 }
379
380 tierRatio, err := self.tierRatio.Get(tierNum)
381 if err != nil {
382 panic(makeErrorWithDetails(errInvalidPoolTier, err.Error()))
383 }
384 tierRatioInt64 := int64(tierRatio)
385
386 counts := self.CurrentAllTierCounts()
387 tierCount := int64(counts[tierNum])
388 if tierCount == 0 {
389 return 0 // No pools in tier
390 }
391
392 return calculatePoolReward(self.getEmission(), tierRatioInt64, tierCount)
393}
394
395// calculatePoolReward calculates the reward for a pool based on the emission, tier ratio, and tier count.
396//
397// Parameters:
398// - emission: The emission for the pool.
399// - tierRatio: The tier ratio for the pool.
400// - tierCount: The tier count for the pool.
401//
402// Returns:
403// - int64: The reward for the pool.
404func calculatePoolReward(emission int64, tierRatio int64, tierCount int64) int64 {
405 if emission < 0 || tierRatio < 0 || tierCount < 0 {
406 panic(errors.New(errCalculationError))
407 }
408
409 if emission == 0 || tierRatio == 0 || tierCount == 0 {
410 return 0
411 }
412
413 tierReward := gnsmath.SafeMulDivInt64(emission, tierRatio, 100)
414
415 return tierReward / tierCount
416}
417
418// computeTierRewards caches per-tier pool rewards to avoid recalculating for each pool iteration.
419func (self *PoolTier) computeTierRewards(emission int64) map[uint64]int64 {
420 tierRewards := make(map[uint64]int64, AllTierCount-1)
421
422 for tierNum := uint64(1); tierNum < AllTierCount; tierNum++ {
423 tierCount := int64(self.counts[tierNum])
424 if tierCount == 0 {
425 continue
426 }
427
428 tierRatio, err := self.tierRatio.Get(tierNum)
429 if err != nil {
430 panic(makeErrorWithDetails(errInvalidPoolTier, err.Error()))
431 }
432
433 tierRewards[tierNum] = calculatePoolReward(emission, int64(tierRatio), tierCount)
434 }
435
436 return tierRewards
437}