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

13.98 Kb · 363 lines
  1package staker
  2
  3import (
  4	"gno.land/p/gnoswap/gnsmath"
  5	ufmt "gno.land/p/nt/ufmt/v0"
  6
  7	sr "gno.land/r/gnoswap/staker"
  8)
  9
 10// Reward is a struct for storing reward for a position.
 11// Internal reward is the GNS reward, external reward is the reward for other incentives.
 12// Penalties are the amount that is deducted from the reward due to the position's warmup.
 13type Reward struct {
 14	Internal        int64
 15	InternalPenalty int64
 16	External        map[string]int64 // Incentive ID -> TokenAmount
 17	ExternalPenalty map[string]int64 // Incentive ID -> TokenAmount
 18}
 19
 20// aggregateRewards sums the per-warmup rewards/penalties into a single Reward.
 21func aggregateRewards(rewards []Reward) Reward {
 22	internal := int64(0)
 23	internalPenalty := int64(0)
 24
 25	rewardLen := len(rewards)
 26	externalReward := make(map[string]int64, rewardLen)
 27	externalPenalty := make(map[string]int64, rewardLen)
 28
 29	for _, reward := range rewards {
 30		internal = gnsmath.SafeAddInt64(internal, reward.Internal)
 31		internalPenalty = gnsmath.SafeAddInt64(internalPenalty, reward.InternalPenalty)
 32
 33		for incentive, amount := range reward.External {
 34			externalReward[incentive] = gnsmath.SafeAddInt64(externalReward[incentive], amount)
 35		}
 36
 37		for incentive, penalty := range reward.ExternalPenalty {
 38			externalPenalty[incentive] = gnsmath.SafeAddInt64(externalPenalty[incentive], penalty)
 39		}
 40	}
 41
 42	return Reward{
 43		Internal:        internal,
 44		InternalPenalty: internalPenalty,
 45		External:        externalReward,
 46		ExternalPenalty: externalPenalty,
 47	}
 48}
 49
 50// calculatePositionRewardParam is a struct for calculating position reward
 51type calculatePositionRewardParam struct {
 52	// Environmental variables
 53	CurrentHeight int64
 54	CurrentTime   int64
 55	Deposits      *Deposits
 56	Pools         *Pools
 57	PoolTier      *PoolTier
 58
 59	// Position variables
 60	PositionId uint64
 61}
 62
 63// positionRewardUpdate carries the persisted-state changes produced (but not applied) by
 64// calculatePositionReward. They are applied by updatePositionReward, so that only the collect path
 65// mutates state while the calculation stays pure.
 66type positionRewardUpdate struct {
 67	poolPath    string
 68	pool        *sr.Pool
 69	poolExisted bool
 70
 71	// External incentive ids discovered as newly created since the deposit's last update; they must be
 72	// added to the deposit's incentive index.
 73	newExternalIncentiveIds []string
 74	// Whether the deposit's LastExternalIncentiveUpdatedAt cursor should be advanced to CurrentTime.
 75	advanceExternalIncentiveCursor bool
 76}
 77
 78// calculateCollectablePositionReward calculates the aggregated position reward WITHOUT mutating any persisted state.
 79//
 80// It is the shared, read-only entry point used by both the Collectable* view getters and CollectReward.
 81// This keeps the calculation identical for both callers and guarantees views never write.
 82func (s *stakerV1) calculateCollectablePositionReward(currentHeight, currentTimestamp int64, positionId uint64) Reward {
 83	rewards, _ := s.calculatePositionReward(&calculatePositionRewardParam{
 84		CurrentHeight: currentHeight,
 85		CurrentTime:   currentTimestamp,
 86		Deposits:      s.getDeposits(),
 87		Pools:         s.getPools(),
 88		PoolTier:      s.getPoolTier(),
 89		PositionId:    positionId,
 90	})
 91
 92	return aggregateRewards(rewards)
 93}
 94
 95// calculatePositionReward computes a position's per-warmup rewards WITHOUT mutating persisted state.
 96//
 97// The per-second emission-rate schedule is resolved up-front (resolveInternalRewardSegments) and fed to
 98// the single internal-reward calculator, so the calculation needs no reward-cache materialization.
 99// External incentive discovery is done into a local set rather than by mutating the deposit. All would-be
100// state changes are returned as a positionRewardUpdate for the caller to apply (collect only).
101func (s *stakerV1) calculatePositionReward(param *calculatePositionRewardParam) ([]Reward, positionRewardUpdate) {
102	deposit := param.Deposits.get(param.PositionId)
103	depositResolver := NewDepositResolver(deposit)
104	poolPath := deposit.TargetPoolPath()
105
106	pool, poolExisted := param.Pools.Get(poolPath)
107	if !poolExisted {
108		// Read-only: use an ephemeral pool; persistence is deferred to updatePositionReward.
109		pool = sr.NewPool(poolPath, param.CurrentTime)
110	}
111	poolResolver := NewPoolResolver(pool)
112
113	updateParams := positionRewardUpdate{
114		poolPath:    poolPath,
115		pool:        pool,
116		poolExisted: poolExisted,
117	}
118
119	lastCollectTime := depositResolver.InternalRewardLastCollectTime()
120
121	// Initializes reward/penalty arrays for rewards and penalties for each warmup
122	rewardState := poolResolver.RewardStateOf(deposit)
123
124	// Resolve the per-second reward-rate schedule (pure) and calculate internal rewards from it.
125	internalSegments := poolResolver.resolveInternalRewardSegments(param.PoolTier, poolPath, lastCollectTime, param.CurrentTime)
126	calculatedInternalRewards, calculatedInternalPenalties := rewardState.calculateInternalReward(internalSegments)
127
128	warmupLen := len(deposit.Warmups())
129	rewards := make([]Reward, warmupLen)
130	for i := 0; i < warmupLen; i++ {
131		rewards[i] = Reward{
132			Internal:        calculatedInternalRewards[i],
133			InternalPenalty: calculatedInternalPenalties[i],
134			External:        make(map[string]int64),
135			ExternalPenalty: make(map[string]int64),
136		}
137	}
138	rewardState.reset()
139
140	// Build the effective incentive-id set (stored ∪ newly-created-since-last-update) WITHOUT mutating
141	// the deposit. ExternalRewardLastCollectTime falls back to StakeTime for ids not yet persisted, so a
142	// newly-discovered incentive yields the same calculation whether or not it is written to the deposit.
143	seen := make(map[string]bool)
144	incentiveIds := make([]string, 0)
145	deposit.IterateExternalIncentiveIds(func(incentiveId string) bool {
146		if !seen[incentiveId] {
147			seen[incentiveId] = true
148			incentiveIds = append(incentiveIds, incentiveId)
149		}
150		return false
151	})
152
153	lastExternalIncentiveUpdatedAt := depositResolver.LastExternalIncentiveUpdatedAt()
154	if lastExternalIncentiveUpdatedAt < param.CurrentTime {
155		// Discover incentives from this pool's own start-time index. Using the
156		// local resolver keeps calculation read-only even for an ephemeral pool
157		// that has not yet been persisted in param.Pools.
158		newIds := make([]string, 0)
159		poolResolver.IncentivesResolver().IterateIncentiveIdsByTime(lastExternalIncentiveUpdatedAt, param.CurrentTime, func(incentiveId string) bool {
160			newIds = append(newIds, incentiveId)
161			return false
162		})
163		updateParams.newExternalIncentiveIds = newIds
164		updateParams.advanceExternalIncentiveCursor = true
165
166		for _, incentiveId := range newIds {
167			if !seen[incentiveId] {
168				seen[incentiveId] = true
169				incentiveIds = append(incentiveIds, incentiveId)
170			}
171		}
172	}
173
174	incentivesResolver := poolResolver.IncentivesResolver()
175	for _, incentiveId := range incentiveIds {
176		incentive, ok := incentivesResolver.Get(incentiveId)
177		if !ok {
178			continue
179		}
180
181		incentiveResolver := NewExternalIncentiveResolver(incentive)
182
183		// Check if incentive is active during this specific collection period
184		if !incentiveResolver.IsStarted(param.CurrentTime) {
185			continue
186		}
187
188		// External incentivized pool.
189		// Calculate reward for each warmup using per-incentive lastCollectTime
190		externalLastCollectTime := depositResolver.ExternalRewardLastCollectTime(incentiveId)
191		externalReward, externalPenalty := rewardState.calculateExternalReward(externalLastCollectTime, param.CurrentTime, incentive)
192
193		for i := range externalReward {
194			if externalReward[i] > 0 || externalPenalty[i] > 0 {
195				rewards[i].External[incentiveId] = externalReward[i]
196				rewards[i].ExternalPenalty[incentiveId] = externalPenalty[i]
197			}
198		}
199
200		rewardState.reset()
201	}
202
203	return rewards, updateParams
204}
205
206// updatePositionReward applies the persisted-state changes produced by calculatePositionReward.
207// It is called ONLY by the collect path; the Collectable* view getters discard the update.
208func (s *stakerV1) updatePositionReward(param *calculatePositionRewardParam, updateParams positionRewardUpdate) {
209	// Persist a lazily created pool.
210	if !updateParams.poolExisted {
211		param.Pools.set(updateParams.poolPath, updateParams.pool)
212	}
213
214	// Materialize the reward cache up to CurrentTime (halving boundaries). The calculation itself no
215	// longer needs this, but downstream unclaimable processing (which reads CurrentReward) and off-chain
216	// history rely on the cache, so it is advanced here on collect only.
217	param.PoolTier.cacheRewardForPool(param.CurrentTime, param.Pools, updateParams.poolPath)
218
219	// Persist deposit incentive-index updates discovered during calculation.
220	if len(updateParams.newExternalIncentiveIds) > 0 || updateParams.advanceExternalIncentiveCursor {
221		deposit := param.Deposits.get(param.PositionId)
222		for _, incentiveId := range updateParams.newExternalIncentiveIds {
223			deposit.AddExternalIncentiveId(incentiveId)
224		}
225		if updateParams.advanceExternalIncentiveCursor {
226			deposit.SetLastExternalIncentiveUpdatedAt(param.CurrentTime)
227		}
228	}
229}
230
231// internalRewardSegment is a [start, end) span over which the per-second emission reward rate is constant.
232type internalRewardSegment struct {
233	start           int64
234	end             int64
235	rewardPerSecond int64
236}
237
238// resolveInternalRewardSegments builds the per-second reward-rate schedule over [startTime, endTime]
239// WITHOUT mutating state.
240//
241// Persisted reward-cache entries cover the historical portion: they record tier/count changes and any
242// halvings already materialized by past collects, so no un-materialized halving exists strictly between
243// two persisted entries. The tail beyond the last persisted entry is split by halvings using the current
244// tier ratio/count, which are necessarily constant there (a change would have written a cache entry). The
245// tail rate is recomputed with the same calculatePoolReward arithmetic the cache writer uses, so a
246// schedule resolved from a fully materialized cache and one resolved from the emission halvings are
247// identical.
248func (self *PoolResolver) resolveInternalRewardSegments(poolTier *PoolTier, poolPath string, startTime, endTime int64) []internalRewardSegment {
249	segments := make([]internalRewardSegment, 0)
250	if startTime >= endTime {
251		return segments
252	}
253
254	currentReward := self.CurrentReward(startTime)
255	cursor := startTime
256
257	self.RewardCache().Iterate(startTime, endTime, func(key int64, value any) bool {
258		reward, ok := value.(int64)
259		if !ok {
260			panic(ufmt.Sprintf("failed to cast value to int64: %T", value))
261		}
262
263		segments = append(segments, internalRewardSegment{start: cursor, end: key, rewardPerSecond: currentReward})
264		cursor = key
265		currentReward = reward
266		return false
267	})
268
269	if cursor < endTime {
270		segments = appendInternalRewardTailSegments(segments, poolTier, poolPath, cursor, endTime, currentReward)
271	}
272
273	return segments
274}
275
276// appendInternalRewardTailSegments appends the schedule for the tail [startTime, endTime], where no
277// persisted cache entry exists beyond startTime. Over this span tier/count are constant, so the rate
278// changes only at halving boundaries.
279func appendInternalRewardTailSegments(segments []internalRewardSegment, poolTier *PoolTier, poolPath string, startTime, endTime, baseReward int64) []internalRewardSegment {
280	tier := poolTier.CurrentTier(poolPath)
281	if tier == 0 || tier >= AllTierCount {
282		// Not currently tiered: the rate cannot increase; the base is 0 after de-tier.
283		return append(segments, internalRewardSegment{start: startTime, end: endTime, rewardPerSecond: baseReward})
284	}
285
286	tierRatio, err := poolTier.tierRatio.Get(tier)
287	if err != nil {
288		panic(makeErrorWithDetails(errInvalidPoolTier, err.Error()))
289	}
290	tierRatioInt64 := int64(tierRatio)
291	tierCount := int64(poolTier.counts[tier])
292
293	halvingTimestamps, halvingEmissions := poolTier.getHalvingBlocksInRange(startTime, endTime)
294
295	segStart := startTime
296	rate := baseReward
297	for i, hv := range halvingTimestamps {
298		if hv <= segStart {
299			// Halving effective at/before the segment start: only switch the rate.
300			rate = calculatePoolReward(halvingEmissions[i], tierRatioInt64, tierCount)
301			continue
302		}
303		if hv >= endTime {
304			break
305		}
306
307		segments = append(segments, internalRewardSegment{start: segStart, end: hv, rewardPerSecond: rate})
308		rate = calculatePoolReward(halvingEmissions[i], tierRatioInt64, tierCount)
309		segStart = hv
310	}
311
312	return append(segments, internalRewardSegment{start: segStart, end: endTime, rewardPerSecond: rate})
313}
314
315// calculates internal unclaimable reward for the pool
316func (s *stakerV1) processUnClaimableReward(poolPath string, endTimestamp int64) int64 {
317	pool, ok := s.getPools().Get(poolPath)
318	if !ok {
319		return 0
320	}
321	poolResolver := NewPoolResolver(pool)
322
323	return poolResolver.processUnclaimableReward(endTimestamp)
324}
325
326// update deposit's incentive list with new incentives created since last update
327func (s *stakerV1) getExternalIncentiveIdsBy(poolPath string, startTime, endTime int64) []string {
328	currentIncentiveIds := make([]string, 0)
329
330	pool, ok := s.getPools().Get(poolPath)
331	if !ok {
332		return currentIncentiveIds
333	}
334	poolResolver := NewPoolResolver(pool)
335
336	// Look up the pool's own start-time index instead of a global
337	// creation-time index. The index is scoped to this pool's incentives, so
338	// discovery cost is bounded by the number of incentives for this pool
339	// within the queried range, and no longer grows with the total number of
340	// incentives system-wide.
341	poolResolver.IncentivesResolver().IterateIncentiveIdsByTime(startTime, endTime, func(incentiveId string) bool {
342		currentIncentiveIds = append(currentIncentiveIds, incentiveId)
343		return false
344	})
345
346	return currentIncentiveIds
347}
348
349// getInitialCollectTime determines the initial collection time for an incentive
350// by taking the maximum of the deposit's stake time and the incentive's start time.
351// This ensures rewards are only calculated from when both conditions are met:
352// - The position must be staked (deposit.stakeTime)
353// - The incentive must be active (incentive.startTimestamp)
354//
355// This function is used for lazy initialization when a position collects
356// from an incentive for the first time, avoiding the need to iterate through
357// all deposits when a new incentive is created.
358func getInitialCollectTime(deposit *sr.Deposit, incentive *sr.ExternalIncentive) int64 {
359	if deposit.StakeTime() > incentive.StartTimestamp() {
360		return deposit.StakeTime()
361	}
362	return incentive.StartTimestamp()
363}