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}