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

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}