calculate_pool_position_reward.gno
21.23 Kb · 552 lines
1package staker
2
3import (
4 "time"
5
6 "gno.land/p/gnoswap/gnsmath/v1"
7 ufmt "gno.land/p/nt/ufmt/v0"
8
9 sr "gno.land/r/gnoswap/staker"
10)
11
12// Reward is a struct for storing reward for a position.
13// Internal reward is the GNS reward, external reward is the reward for other incentives.
14// Penalties are the amount that is deducted from the reward due to the position's warmup.
15type Reward struct {
16 Internal int64
17 InternalPenalty int64
18 External map[string]int64 // Incentive ID -> TokenAmount
19 ExternalPenalty map[string]int64 // Incentive ID -> TokenAmount
20}
21
22// aggregateRewards sums the per-warmup rewards/penalties into a single Reward.
23func aggregateRewards(rewards []Reward) Reward {
24 internal := int64(0)
25 internalPenalty := int64(0)
26
27 rewardLen := len(rewards)
28 externalReward := make(map[string]int64, rewardLen)
29 externalPenalty := make(map[string]int64, rewardLen)
30
31 for _, reward := range rewards {
32 internal = gnsmath.SafeAddInt64(internal, reward.Internal)
33 internalPenalty = gnsmath.SafeAddInt64(internalPenalty, reward.InternalPenalty)
34
35 for incentive, amount := range reward.External {
36 externalReward[incentive] = gnsmath.SafeAddInt64(externalReward[incentive], amount)
37 }
38
39 for incentive, penalty := range reward.ExternalPenalty {
40 externalPenalty[incentive] = gnsmath.SafeAddInt64(externalPenalty[incentive], penalty)
41 }
42 }
43
44 return Reward{
45 Internal: internal,
46 InternalPenalty: internalPenalty,
47 External: externalReward,
48 ExternalPenalty: externalPenalty,
49 }
50}
51
52// calculatePositionRewardParam is a struct for calculating position reward
53type calculatePositionRewardParam struct {
54 // Environmental variables
55 CurrentHeight int64
56 CurrentTime int64
57 Deposits *Deposits
58 Pools *Pools
59 PoolTier *PoolTier
60
61 // Position variables
62 PositionId uint64
63
64 // Deposit overrides the Deposits lookup for a deposit that is no longer in the tree.
65 Deposit *sr.Deposit
66 // Exit pins the pool state to the moment the position left the pool; nil while staked.
67 Exit *sr.UnstakedPosition
68}
69
70// deposit resolves the deposit the calculation runs against.
71func (self *calculatePositionRewardParam) deposit() *sr.Deposit {
72 if self.Deposit != nil {
73 return self.Deposit
74 }
75
76 return self.Deposits.get(self.PositionId)
77}
78
79// positionRewardUpdate carries the persisted-state changes produced (but not applied) by a calculation.
80// They are applied by the update* functions, so that only the collect path mutates state while the
81// calculation stays pure.
82type positionRewardUpdate struct {
83 poolPath string
84 pool *sr.Pool
85 poolExisted bool
86
87 // External incentive ids discovered as newly created since the deposit's last update; they must be
88 // added to the deposit's incentive index.
89 newExternalIncentiveIds []string
90 // Whether the deposit's LastExternalIncentiveUpdatedAt cursor should be advanced to CurrentTime.
91 advanceExternalIncentiveCursor bool
92
93 // Incentive ids the calculation actually produced a reward for, in calculation order. The collect
94 // path walks this instead of iterating the reward map, so delivery order is deterministic.
95 externalIncentiveIds []string
96}
97
98// positionRewardContext carries the per-position state that both the internal (GNS emission) and the
99// external (incentive) reward calculations need. Resolving it once lets each path be calculated on its
100// own without duplicating the deposit/pool/reward-state setup.
101type positionRewardContext struct {
102 deposit *sr.Deposit
103 depositResolver *DepositResolver
104 poolResolver *PoolResolver
105 rewardState *RewardState
106 warmupLen int
107
108 update positionRewardUpdate
109}
110
111// newPositionRewardContext resolves the deposit, its pool and a fresh RewardState WITHOUT mutating
112// persisted state. A missing pool is created ephemerally and its persistence is deferred to the
113// update path.
114func (s *stakerV1) newPositionRewardContext(param *calculatePositionRewardParam) *positionRewardContext {
115 deposit := param.deposit()
116 poolPath := deposit.TargetPoolPath()
117
118 pool, poolExisted := param.Pools.Get(poolPath)
119 if !poolExisted {
120 // Read-only: use an ephemeral pool; persistence is deferred to updatePositionReward.
121 pool = sr.NewPool(poolPath, param.CurrentTime)
122 }
123 poolResolver := newPoolResolverWithExit(pool, param.Exit)
124
125 return &positionRewardContext{
126 deposit: deposit,
127 depositResolver: NewDepositResolver(deposit),
128 poolResolver: poolResolver,
129 rewardState: poolResolver.RewardStateOf(deposit),
130 warmupLen: len(deposit.Warmups()),
131 update: positionRewardUpdate{
132 poolPath: poolPath,
133 pool: pool,
134 poolExisted: poolExisted,
135 },
136 }
137}
138
139// newRewards allocates the empty per-warmup reward slice the compute* methods write into.
140func (self *positionRewardContext) newRewards() []Reward {
141 rewards := make([]Reward, self.warmupLen)
142 for i := 0; i < self.warmupLen; i++ {
143 rewards[i] = Reward{
144 External: make(map[string]int64),
145 ExternalPenalty: make(map[string]int64),
146 }
147 }
148
149 return rewards
150}
151
152// computeInternalRewards writes the GNS emission reward and penalty of each warmup into rewards.
153//
154// The per-second emission-rate schedule is resolved up-front (resolveInternalRewardSegments) and fed to
155// the single internal-reward calculator, so the calculation needs no reward-cache update.
156func (self *positionRewardContext) computeInternalRewards(param *calculatePositionRewardParam, rewards []Reward) error {
157 lastCollectTime := self.depositResolver.InternalRewardLastCollectTime()
158
159 // Resolve the per-second reward-rate schedule (pure) and calculate internal rewards from it.
160 internalSegments, err := self.poolResolver.resolveInternalRewardSegments(param.PoolTier, self.update.poolPath, lastCollectTime, param.CurrentTime)
161 if err != nil {
162 return err
163 }
164 calculatedInternalRewards, calculatedInternalPenalties := self.rewardState.calculateInternalReward(internalSegments)
165
166 for i := 0; i < self.warmupLen; i++ {
167 rewards[i].Internal = calculatedInternalRewards[i]
168 rewards[i].InternalPenalty = calculatedInternalPenalties[i]
169 }
170
171 self.rewardState.reset()
172
173 return nil
174}
175
176// discoverExternalIncentives records the incentives created since the deposit's last update on the
177// update, WITHOUT mutating the deposit. The collect path adds them to the deposit's incentive index.
178//
179// ExternalRewardLastCollectTime falls back to StakeTime for ids not yet persisted, so a newly-discovered
180// incentive yields the same calculation whether or not it is written to the deposit.
181func (self *positionRewardContext) discoverExternalIncentives(param *calculatePositionRewardParam) {
182 lastExternalIncentiveUpdatedAt := self.depositResolver.LastExternalIncentiveUpdatedAt()
183 if lastExternalIncentiveUpdatedAt >= param.CurrentTime {
184 return
185 }
186
187 // Discover incentives from this pool's own start-time index. Using the
188 // local resolver keeps calculation read-only even for an ephemeral pool
189 // that has not yet been persisted in param.Pools.
190 newIds := make([]string, 0)
191 self.poolResolver.IncentivesResolver().IterateIncentiveIdsByTime(lastExternalIncentiveUpdatedAt, param.CurrentTime, func(incentiveId string) bool {
192 newIds = append(newIds, incentiveId)
193 return false
194 })
195
196 self.update.newExternalIncentiveIds = newIds
197 self.update.advanceExternalIncentiveCursor = true
198}
199
200// externalIncentiveIds returns the deposit's effective incentive-id set: the ids stored on the deposit
201// plus the ones discoverExternalIncentives found. Call it after discoverExternalIncentives.
202func (self *positionRewardContext) externalIncentiveIds() []string {
203 seen := make(map[string]bool)
204 incentiveIds := make([]string, 0)
205
206 self.deposit.IterateExternalIncentiveIds(func(incentiveId string) bool {
207 if !seen[incentiveId] {
208 seen[incentiveId] = true
209 incentiveIds = append(incentiveIds, incentiveId)
210 }
211 return false
212 })
213
214 for _, incentiveId := range self.update.newExternalIncentiveIds {
215 if !seen[incentiveId] {
216 seen[incentiveId] = true
217 incentiveIds = append(incentiveIds, incentiveId)
218 }
219 }
220
221 return incentiveIds
222}
223
224// computeExternalReward writes one incentive's external reward and penalty of each warmup into rewards.
225// An incentive that does not exist on the pool or has not started yet contributes nothing.
226func (self *positionRewardContext) computeExternalReward(param *calculatePositionRewardParam, rewards []Reward, incentiveId string) {
227 incentive, ok := self.poolResolver.IncentivesResolver().Get(incentiveId)
228 if !ok {
229 return
230 }
231
232 incentiveResolver := NewExternalIncentiveResolver(incentive)
233
234 // Check if incentive is active during this specific collection period
235 if !incentiveResolver.IsStarted(param.CurrentTime) {
236 return
237 }
238
239 // External incentivized pool.
240 // Calculate reward for each warmup using per-incentive lastCollectTime
241 externalLastCollectTime := self.depositResolver.ExternalRewardLastCollectTime(incentiveId)
242 externalReward, externalPenalty := self.rewardState.calculateExternalReward(externalLastCollectTime, param.CurrentTime, incentive)
243
244 for i := range externalReward {
245 if externalReward[i] > 0 || externalPenalty[i] > 0 {
246 rewards[i].External[incentiveId] = externalReward[i]
247 rewards[i].ExternalPenalty[incentiveId] = externalPenalty[i]
248 }
249 }
250
251 self.update.externalIncentiveIds = append(self.update.externalIncentiveIds, incentiveId)
252 self.rewardState.reset()
253}
254
255// computeExternalRewards writes every incentive of the deposit into rewards.
256func (self *positionRewardContext) computeExternalRewards(param *calculatePositionRewardParam, rewards []Reward) {
257 self.discoverExternalIncentives(param)
258
259 for _, incentiveId := range self.externalIncentiveIds() {
260 self.computeExternalReward(param, rewards, incentiveId)
261 }
262}
263
264// calculateCollectablePositionReward calculates the aggregated position reward WITHOUT mutating any persisted state.
265//
266// It is the shared, read-only entry point used by the Collectable* view getters.
267// This keeps the calculation identical for every caller and guarantees views never write.
268func (s *stakerV1) calculateCollectablePositionReward(currentHeight, currentTimestamp int64, positionId uint64) (Reward, error) {
269 param := &calculatePositionRewardParam{
270 CurrentHeight: currentHeight,
271 CurrentTime: currentTimestamp,
272 Deposits: s.getDeposits(),
273 Pools: s.getPools(),
274 PoolTier: s.getPoolTier(),
275 PositionId: positionId,
276 }
277
278 // An unstaked position quotes against its exit checkpoint, at its exit time.
279 if checkpoint := s.getUnstakedPositions().get(positionId); checkpoint != nil {
280 param.CurrentTime = checkpoint.ExitTime()
281 param.Deposit = checkpoint.Deposit()
282 param.Exit = checkpoint
283 }
284
285 rewards, _, err := s.calculatePositionReward(param)
286 if err != nil {
287 return Reward{}, err
288 }
289
290 return aggregateRewards(rewards), nil
291}
292
293// calculatePositionReward computes a position's per-warmup internal AND external rewards WITHOUT
294// mutating persisted state. All would-be state changes are returned as a positionRewardUpdate for the
295// caller to apply (collect only).
296func (s *stakerV1) calculatePositionReward(param *calculatePositionRewardParam) ([]Reward, positionRewardUpdate, error) {
297 ctx := s.newPositionRewardContext(param)
298 rewards := ctx.newRewards()
299
300 if err := ctx.computeInternalRewards(param, rewards); err != nil {
301 return nil, positionRewardUpdate{}, err
302 }
303 ctx.computeExternalRewards(param, rewards)
304
305 return rewards, ctx.update, nil
306}
307
308// calculateExternalPositionRewards computes all of a position's external incentive rewards WITHOUT
309// mutating persisted state. Used by the combined collect path while GNS emission is halted.
310func (s *stakerV1) calculateExternalPositionRewards(param *calculatePositionRewardParam) ([]Reward, positionRewardUpdate) {
311 ctx := s.newPositionRewardContext(param)
312 rewards := ctx.newRewards()
313
314 ctx.computeExternalRewards(param, rewards)
315
316 return rewards, ctx.update
317}
318
319// calculateInternalPositionReward computes only a position's per-warmup GNS emission rewards, WITHOUT
320// mutating persisted state. Used by the emission collect path.
321func (s *stakerV1) calculateInternalPositionReward(param *calculatePositionRewardParam) ([]Reward, positionRewardUpdate, error) {
322 ctx := s.newPositionRewardContext(param)
323 rewards := ctx.newRewards()
324
325 if err := ctx.computeInternalRewards(param, rewards); err != nil {
326 return nil, positionRewardUpdate{}, err
327 }
328
329 return rewards, ctx.update, nil
330}
331
332// calculateExternalPositionReward computes a position's per-warmup reward for one external incentive,
333// WITHOUT mutating persisted state. Used by the external incentive collect path.
334//
335// Incentive discovery still runs over every incentive, so the deposit's incentive index stays complete
336// no matter which incentive the caller asked for.
337func (s *stakerV1) calculateExternalPositionReward(param *calculatePositionRewardParam, incentiveId string) ([]Reward, positionRewardUpdate) {
338 ctx := s.newPositionRewardContext(param)
339 rewards := ctx.newRewards()
340
341 ctx.discoverExternalIncentives(param)
342 ctx.computeExternalReward(param, rewards, incentiveId)
343
344 return rewards, ctx.update
345}
346
347// persistLazyPool persists a pool that calculation created ephemerally because it did not exist yet.
348func (s *stakerV1) persistLazyPool(param *calculatePositionRewardParam, updateParams positionRewardUpdate) {
349 if !updateParams.poolExisted {
350 param.Pools.set(updateParams.poolPath, updateParams.pool)
351 }
352}
353
354// updateInternalRewardCache advances the reward cache up to CurrentTime (halving boundaries). The
355// calculation itself no longer needs this, but downstream unclaimable processing (which reads
356// CurrentReward) and off-chain history rely on the cache, so it is advanced on the emission collect path.
357func (s *stakerV1) updateInternalRewardCache(param *calculatePositionRewardParam, updateParams positionRewardUpdate) {
358 // Always at the current time, never at param.CurrentTime: the cache and the unclaimable
359 // tracking it drives are pool-global, and a checkpoint collect would rewind them to its exit
360 // timestamp, where the pool may have had no staked liquidity.
361 param.PoolTier.cacheRewardForPool(time.Now().Unix(), param.Pools, updateParams.poolPath)
362}
363
364// applyExternalIncentiveIndex persists the deposit incentive-index updates discovered during calculation.
365func (s *stakerV1) applyExternalIncentiveIndex(param *calculatePositionRewardParam, updateParams positionRewardUpdate) {
366 if len(updateParams.newExternalIncentiveIds) == 0 && !updateParams.advanceExternalIncentiveCursor {
367 return
368 }
369
370 deposit := param.deposit()
371 for _, incentiveId := range updateParams.newExternalIncentiveIds {
372 deposit.AddExternalIncentiveId(incentiveId)
373 }
374 if updateParams.advanceExternalIncentiveCursor {
375 deposit.SetLastExternalIncentiveUpdatedAt(param.CurrentTime)
376 }
377}
378
379// updatePositionReward applies the persisted-state changes produced by calculatePositionReward.
380// It is called ONLY by the collect path; the Collectable* view getters discard the update.
381func (s *stakerV1) updatePositionReward(param *calculatePositionRewardParam, updateParams positionRewardUpdate) {
382 s.persistLazyPool(param, updateParams)
383 s.updateInternalRewardCache(param, updateParams)
384 s.applyExternalIncentiveIndex(param, updateParams)
385}
386
387// updateInternalPositionReward applies the state changes belonging to the emission collect path only.
388func (s *stakerV1) updateInternalPositionReward(param *calculatePositionRewardParam, updateParams positionRewardUpdate) {
389 s.persistLazyPool(param, updateParams)
390 s.updateInternalRewardCache(param, updateParams)
391}
392
393// updateExternalPositionReward applies the state changes belonging to the external collect path only.
394func (s *stakerV1) updateExternalPositionReward(param *calculatePositionRewardParam, updateParams positionRewardUpdate) {
395 s.persistLazyPool(param, updateParams)
396 s.applyExternalIncentiveIndex(param, updateParams)
397}
398
399// internalRewardSegment is a [start, end) span over which the per-second emission reward rate is constant.
400type internalRewardSegment struct {
401 start int64
402 end int64
403 rewardPerSecond int64
404}
405
406// resolveInternalRewardSegments builds the per-second reward-rate schedule over [startTime, endTime]
407// WITHOUT mutating state.
408//
409// Persisted reward-cache entries cover the historical portion: they record tier/count changes and any
410// halvings already materialized by past collects, so no un-materialized halving exists strictly between
411// two persisted entries. The tail beyond the last persisted entry is split by halvings using the current
412// tier ratio/count, which are necessarily constant there (a change would have written a cache entry). The
413// tail rate is recomputed with the same calculatePoolReward arithmetic the cache writer uses, so a
414// schedule resolved from a fully materialized cache and one resolved from the emission halvings are
415// identical.
416func (self *PoolResolver) resolveInternalRewardSegments(poolTier *PoolTier, poolPath string, startTime, endTime int64) ([]internalRewardSegment, error) {
417 segments := make([]internalRewardSegment, 0)
418 if startTime >= endTime {
419 return segments, nil
420 }
421
422 currentReward := self.CurrentReward(startTime)
423 cursor := startTime
424
425 self.RewardCache().Iterate(startTime, endTime, func(key int64, value any) bool {
426 reward, ok := value.(int64)
427 if !ok {
428 panic(ufmt.Sprintf("failed to cast value to int64: %T", value))
429 }
430
431 segments = append(segments, internalRewardSegment{start: cursor, end: key, rewardPerSecond: currentReward})
432 cursor = key
433 currentReward = reward
434 return false
435 })
436
437 if cursor < endTime {
438 var err error
439 segments, err = appendInternalRewardTailSegments(segments, poolTier, poolPath, cursor, endTime, currentReward, self.exit)
440 if err != nil {
441 return nil, err
442 }
443 }
444
445 return segments, nil
446}
447
448// appendInternalRewardTailSegments appends the schedule for the tail [startTime, endTime], where no
449// persisted cache entry exists beyond startTime. Over this span tier/count are constant, so the rate
450// changes only at halving boundaries.
451func appendInternalRewardTailSegments(segments []internalRewardSegment, poolTier *PoolTier, poolPath string, startTime, endTime, baseReward int64, exit *sr.UnstakedPosition) ([]internalRewardSegment, error) {
452 // A checkpoint reads the tier context it exited under: this window closed at the exit, and
453 // the live lookup is skipped so a checkpoint collect never depends on live tier state.
454 var tier, ratio, count uint64
455 if exit != nil {
456 tier, ratio, count = exit.Tier(), exit.TierRatio(), exit.TierCount()
457 } else {
458 var err error
459 tier, ratio, count, err = poolTier.tierContextOf(poolPath)
460 if err != nil {
461 return nil, err
462 }
463 }
464
465 if tier == 0 || tier >= AllTierCount {
466 // Not currently tiered: the rate cannot increase; the base is 0 after de-tier.
467 return append(segments, internalRewardSegment{start: startTime, end: endTime, rewardPerSecond: baseReward}), nil
468 }
469
470 tierRatioInt64 := int64(ratio)
471 tierCount := int64(count)
472
473 halvingTimestamps, halvingEmissions, err := poolTier.getHalvingBlocksInRange(startTime, endTime)
474 if err != nil {
475 return nil, err
476 }
477
478 segStart := startTime
479 rate := baseReward
480 for i, hv := range halvingTimestamps {
481 if hv <= segStart {
482 // Halving effective at/before the segment start: only switch the rate.
483 rate, err = calculatePoolReward(halvingEmissions[i], tierRatioInt64, tierCount)
484 if err != nil {
485 return nil, err
486 }
487 continue
488 }
489 if hv >= endTime {
490 break
491 }
492
493 segments = append(segments, internalRewardSegment{start: segStart, end: hv, rewardPerSecond: rate})
494 rate, err = calculatePoolReward(halvingEmissions[i], tierRatioInt64, tierCount)
495 if err != nil {
496 return nil, err
497 }
498 segStart = hv
499 }
500
501 return append(segments, internalRewardSegment{start: segStart, end: endTime, rewardPerSecond: rate}), nil
502}
503
504// calculates internal unclaimable reward for the pool
505func (s *stakerV1) processUnClaimableReward(poolPath string, endTimestamp int64) int64 {
506 pool, ok := s.getPools().Get(poolPath)
507 if !ok {
508 return 0
509 }
510 poolResolver := NewPoolResolver(pool)
511
512 return poolResolver.processUnclaimableReward(endTimestamp)
513}
514
515// update deposit's incentive list with new incentives created since last update
516func (s *stakerV1) getExternalIncentiveIdsBy(poolPath string, startTime, endTime int64) []string {
517 currentIncentiveIds := make([]string, 0)
518
519 pool, ok := s.getPools().Get(poolPath)
520 if !ok {
521 return currentIncentiveIds
522 }
523 poolResolver := NewPoolResolver(pool)
524
525 // Look up the pool's own start-time index instead of a global
526 // creation-time index. The index is scoped to this pool's incentives, so
527 // discovery cost is bounded by the number of incentives for this pool
528 // within the queried range, and no longer grows with the total number of
529 // incentives system-wide.
530 poolResolver.IncentivesResolver().IterateIncentiveIdsByTime(startTime, endTime, func(incentiveId string) bool {
531 currentIncentiveIds = append(currentIncentiveIds, incentiveId)
532 return false
533 })
534
535 return currentIncentiveIds
536}
537
538// getInitialCollectTime determines the initial collection time for an incentive
539// by taking the maximum of the deposit's stake time and the incentive's start time.
540// This ensures rewards are only calculated from when both conditions are met:
541// - The position must be staked (deposit.stakeTime)
542// - The incentive must be active (incentive.startTimestamp)
543//
544// This function is used for lazy initialization when a position collects
545// from an incentive for the first time, avoiding the need to iterate through
546// all deposits when a new incentive is created.
547func getInitialCollectTime(deposit *sr.Deposit, incentive *sr.ExternalIncentive) int64 {
548 if deposit.StakeTime() > incentive.StartTimestamp() {
549 return deposit.StakeTime()
550 }
551 return incentive.StartTimestamp()
552}