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

reward_calculation_pool.gno

27.85 Kb · 784 lines
  1package staker
  2
  3import (
  4	"errors"
  5	"math"
  6	"time"
  7
  8	"gno.land/p/gnoswap/gnsmath/v1"
  9	bptree "gno.land/p/nt/bptree/v0"
 10	ufmt "gno.land/p/nt/ufmt/v0"
 11
 12	i256 "gno.land/p/gnoswap/int256/v1"
 13	u256 "gno.land/p/gnoswap/uint256/v1"
 14	sr "gno.land/r/gnoswap/staker"
 15)
 16
 17var q128 = u256.MustFromDecimal("340282366920938463463374607431768211456")
 18
 19// Pools represents the global pool storage
 20type Pools struct {
 21	tree *bptree.BPTree // string poolPath -> pool
 22}
 23
 24// NewPools creates an empty resolver for global pool storage.
 25//
 26// Returns:
 27//   - pools: Pools resolver backed by a new B+ tree keyed by pool path.
 28func NewPools() *Pools {
 29	return &Pools{
 30		tree: sr.NewBPTreeN(16),
 31	}
 32}
 33
 34// Get returns the pool stored under the given pool path.
 35//
 36// Parameters:
 37//   - poolPath: Pool path used as the storage key.
 38//
 39// Returns:
 40//   - pool: Stored pool pointer when poolPath exists; nil when absent.
 41//   - found: True when poolPath resolves to a pool; false when no entry exists.
 42func (self *Pools) Get(poolPath string) (*sr.Pool, bool) {
 43	v := self.tree.Get(poolPath)
 44	if v == nil {
 45		return nil, false
 46	}
 47	p, ok := v.(*sr.Pool)
 48	if !ok {
 49		panic(ufmt.Sprintf("failed to cast v to *Pool: %T", v))
 50	}
 51	return p, true
 52}
 53
 54// GetPoolOrNil returns the pool for the given pool path, or nil when it does not exist.
 55//
 56// Parameters:
 57//   - poolPath: Pool path used as the storage key.
 58//
 59// Returns:
 60//   - pool: Stored pool pointer, or nil when poolPath is absent.
 61func (self *Pools) GetPoolOrNil(poolPath string) *sr.Pool {
 62	pool, ok := self.Get(poolPath)
 63	if !ok {
 64		return nil
 65	}
 66	return pool
 67}
 68
 69// set sets the pool for the given poolPath.
 70func (self *Pools) set(poolPath string, pool *sr.Pool) {
 71	self.tree.Set(poolPath, pool)
 72}
 73
 74// Has reports whether a pool exists for the given pool path.
 75//
 76// Parameters:
 77//   - poolPath: Pool path used as the storage key.
 78//
 79// Returns:
 80//   - exists: True when poolPath is present in the pool tree; false otherwise.
 81func (self *Pools) Has(poolPath string) bool {
 82	return self.tree.Has(poolPath)
 83}
 84
 85// IterateAll visits every stored pool until the callback requests that traversal stop.
 86//
 87// Parameters:
 88//   - fn: Callback receiving each pool path and pool pointer; return true to stop iteration, false to continue.
 89func (self *Pools) IterateAll(fn func(key string, pool *sr.Pool) bool) {
 90	self.tree.Iterate("", "", func(key string, value any) bool {
 91		p, ok := value.(*sr.Pool)
 92		if !ok {
 93			panic(ufmt.Sprintf("failed to cast value to *Pool: %T", value))
 94		}
 95		return fn(key, p)
 96	})
 97}
 98
 99type PoolResolver struct {
100	*sr.Pool
101
102	// exit is set only when collecting an exit checkpoint, and overrides the pool reads that
103	// would otherwise come from state the position no longer takes part in.
104	exit *sr.UnstakedPosition
105}
106
107// IncentivesResolver returns a resolver over the pool's external-incentive state.
108//
109// Returns:
110//   - resolver: Incentives resolver backed by this pool's incentive tree and unclaimable periods.
111func (self *PoolResolver) IncentivesResolver() *IncentivesResolver {
112	return NewIncentivesResolver(self.Incentives())
113}
114
115// CurrentGlobalRewardRatioAccumulation returns the latest stored global reward-ratio checkpoint in the [0, currentTime] range.
116//
117// Parameters:
118//   - currentTime: Unix timestamp bounding the checkpoint lookup.
119//
120// Returns:
121//   - time: Timestamp of the latest stored checkpoint at or before currentTime; zero when no checkpoint exists.
122//   - acc: Decimal-encoded Q128-scaled global reward-ratio accumulation at time.
123func (self *PoolResolver) CurrentGlobalRewardRatioAccumulation(currentTime int64) (time int64, acc string) {
124	acc = "0"
125
126	self.GlobalRewardRatioAccumulation().ReverseIterate(0, currentTime, func(key int64, value any) bool {
127		time = key
128
129		valueStr, ok := value.(string)
130		if !ok {
131			panic(ufmt.Sprintf("failed to cast value to string: %T", value))
132		}
133
134		acc = valueStr
135
136		return true
137	})
138
139	return time, acc
140}
141
142// CurrentTick returns the latest historical tick in the [0, currentTime] range.
143//
144// Parameters:
145//   - currentTime: Unix timestamp bounding the historical-tick lookup.
146//
147// Returns:
148//   - tick: Latest historical tick at or before currentTime; an exit resolver uses its pinned exit tick at or after exit time.
149func (self *PoolResolver) CurrentTick(currentTime int64) (tick int32) {
150	if self.exit != nil && currentTime >= self.exit.ExitTime() {
151		return self.exit.ExitTick()
152	}
153
154	self.HistoricalTick().ReverseIterate(0, currentTime, func(key int64, value any) bool {
155		res, ok := value.(int32)
156		if !ok {
157			panic(ufmt.Sprintf("failed to cast value to int32: %T", value))
158		}
159		tick = res
160		return true
161	})
162	return tick
163}
164
165// CurrentStakedLiquidity returns the latest staked-liquidity checkpoint in the [0, currentTime] range.
166//
167// Parameters:
168//   - currentTime: Unix timestamp bounding the staked-liquidity lookup.
169//
170// Returns:
171//   - liquidity: Uint256 staked liquidity effective at or before currentTime; zero when no checkpoint exists.
172func (self *PoolResolver) CurrentStakedLiquidity(currentTime int64) (liquidity *u256.Uint) {
173	liquidity = u256.Zero()
174
175	self.StakedLiquidity().ReverseIterate(0, currentTime, func(key int64, value any) bool {
176		res, ok := value.(*u256.Uint)
177		if !ok {
178			panic(ufmt.Sprintf("failed to cast value to *u256.Uint: %T", value))
179		}
180		liquidity = res
181		return true
182	})
183	return liquidity
184}
185
186// GetOrNewTick returns the existing tick or a new zero-valued tick.
187//
188// Parameters:
189//   - tickId: Boundary tick identifier to retrieve or initialize.
190//
191// Returns:
192//   - tick: Existing stored tick, or a zero-valued tick with tickId when no entry exists.
193//
194// Substituting a zero-valued tick on a read is safe because ticks are pruned
195// only when their staked gross liquidity reaches zero, in the same call that
196// removes the last deposit referencing them.
197func (self *PoolResolver) GetOrNewTick(tickId int32) *sr.Tick {
198	if self.exit != nil {
199		if lowerTick := self.exit.LowerTick(); lowerTick != nil && lowerTick.Id() == tickId {
200			return lowerTick
201		}
202		if upperTick := self.exit.UpperTick(); upperTick != nil && upperTick.Id() == tickId {
203			return upperTick
204		}
205	}
206
207	tick := self.Ticks().Get(tickId)
208	if tick == nil {
209		return sr.NewTick(tickId)
210	}
211	return tick
212}
213
214// initializeNewTickOutsideAccumulation seeds a just-created boundary tick so
215// that below(tick) == globalAccumulation holds, keeping the three branches of
216// outsideAccumulationAt returns a boundary tick's outside accumulation at currentTime.
217//
218// An exit checkpoint answers for its own exit timestamp from the value pinned when it was
219// written: a tick cross later in the same block overwrites the entry at that timestamp, while
220// earlier entries can no longer change.
221func (self *PoolResolver) outsideAccumulationAt(tickId int32, currentTime int64) *u256.Uint {
222	if self.exit != nil && currentTime == self.exit.ExitTime() {
223		switch tickId {
224		case self.exit.Deposit().TickLower():
225			return u256.MustFromDecimal(self.exit.LowerOutsideAcc())
226		case self.exit.Deposit().TickUpper():
227			return u256.MustFromDecimal(self.exit.UpperOutsideAcc())
228		}
229	}
230
231	return NewTickResolver(self.GetOrNewTick(tickId)).CurrentOutsideAccumulation(currentTime)
232}
233
234// CalculateRawRewardForPosition consistent.
235func (self *PoolResolver) initializeNewTickOutsideAccumulation(currentTime int64, currentTick, tickId int32, tick *sr.Tick) {
236	if tickId > currentTick {
237		return
238	}
239
240	globalAcc, _ := self.globalRewardRatioAccumulationAt(currentTime)
241	tick.SetOutsideAccumulationAt(currentTime, globalAcc)
242}
243
244// IsExternallyIncentivizedPool reports whether the pool has any external incentive that has not ended,
245// including incentives whose start time is still in the future.
246//
247// Returns:
248//   - incentivized: True when at least one non-ended external incentive is indexed for the pool; false when all are ended or none exist.
249func (self *PoolResolver) IsExternallyIncentivizedPool() bool {
250	currentTime := time.Now().Unix()
251	hasIncentive := false
252	// With a maximum duration of 365 days, older starts have already ended.
253	// Keep future starts eligible and retain historical records for reward claims.
254	self.Incentives().IterateIncentiveIdsByTime(stakeScanLowerBound(currentTime), math.MaxInt64, func(incentiveId string) bool {
255		incentive, ok := self.Incentives().Incentive(incentiveId)
256		if !ok {
257			panic("incentive missing from pool start-time index")
258		}
259
260		resolver := NewExternalIncentiveResolver(incentive)
261		if !resolver.IsEnded(currentTime) {
262			hasIncentive = true
263			return true
264		}
265
266		return false
267	})
268
269	return hasIncentive
270}
271
272// CurrentReward returns the latest cached per-pool reward rate in the [0, currentTime] range.
273//
274// Parameters:
275//   - currentTime: Unix timestamp bounding the reward-cache lookup.
276//
277// Returns:
278//   - reward: Latest cached GNS reward rate at or before currentTime, in units per second; zero when no checkpoint exists.
279func (self *PoolResolver) CurrentReward(currentTime int64) (reward int64) {
280	self.RewardCache().ReverseIterate(0, currentTime, func(key int64, value any) bool {
281		res, ok := value.(int64)
282		if !ok {
283			panic(ufmt.Sprintf("failed to cast value to int64: %T", value))
284		}
285		reward = res
286		return true
287	})
288	return reward
289}
290
291func (self *PoolResolver) isChangedTick(currentTime int64, currentTick int32) bool {
292	if self.HistoricalTick().Size() == 0 {
293		return true
294	}
295
296	previousTick := self.CurrentTick(currentTime)
297
298	return previousTick != currentTick
299}
300
301// cacheReward sets the current reward for the pool
302// If the pool is in unclaimable period, it will end the unclaimable period, updates the reward, and start the unclaimable period again.
303//
304// Important behavior for initial tier assignment:
305// - When a pool first receives a tier, oldTierReward=0 and currentTierReward>0
306// - If the pool has zero liquidity at this point, startUnclaimablePeriod() is called
307// - This ensures unclaimable period tracking begins from the moment rewards start emitting
308func (self *PoolResolver) cacheReward(currentTime int64, currentTierReward int64) {
309	oldTierReward := self.CurrentReward(currentTime)
310	if oldTierReward == currentTierReward {
311		return
312	}
313
314	isInUnclaimable := self.CurrentStakedLiquidity(currentTime).IsZero()
315	if isInUnclaimable {
316		// End any existing unclaimable period
317		// Note: If lastUnclaimableTime is 0 (not yet tracking), this is a no-op
318		self.endUnclaimablePeriod(currentTime)
319	}
320
321	self.Pool.SetRewardCacheAt(currentTime, currentTierReward)
322
323	if isInUnclaimable {
324		// Start/restart unclaimable period tracking
325		// This handles initial tier assignment when lastUnclaimableTime is 0
326		self.startUnclaimablePeriod(currentTime)
327	}
328}
329
330func (self *PoolResolver) calculateGlobalRewardRatioAccumulation(currentTime int64, currentStakedLiquidity *u256.Uint) *u256.Uint {
331	oldAccTime, oldAccStr := self.CurrentGlobalRewardRatioAccumulation(currentTime)
332	timeDiff := gnsmath.SafeSubInt64(currentTime, oldAccTime)
333	if timeDiff == 0 {
334		return u256.MustFromDecimal(oldAccStr)
335	}
336	if timeDiff < 0 {
337		panic("time cannot go backwards")
338	}
339
340	if currentStakedLiquidity.IsZero() {
341		return u256.MustFromDecimal(oldAccStr)
342	}
343
344	oldAcc := u256.MustFromDecimal(oldAccStr)
345	acc := u256.MulDiv(
346		u256.NewUintFromInt64(timeDiff),
347		q128,
348		currentStakedLiquidity,
349	)
350	return u256.Zero().Add(oldAcc, acc)
351}
352
353// globalRewardRatioAccumulationAt returns the global reward ratio accumulation *at* currentTime.
354//
355// CurrentGlobalRewardRatioAccumulation returns the latest stored checkpoint (<= currentTime), which is
356// only equal to the accumulation at currentTime when a checkpoint was written at that very timestamp.
357// Checkpoints are written exclusively by modifyDeposit (staked liquidity changes), so on any other path
358// the stored value lags by (currentTime - lastCheckpointTime) * q128 / stakedLiquidity.
359//
360// Reward calculation never has this problem because it derives the accumulation on demand
361// (CalculateRawRewardForPosition). Event emission must do the same, otherwise off-chain indexers that
362// treat the emitted accumulator as authoritative at the event timestamp silently drop that interval.
363func (self *PoolResolver) globalRewardRatioAccumulationAt(currentTime int64) (*u256.Uint, *u256.Uint) {
364	stakedLiquidity := self.CurrentStakedLiquidity(currentTime)
365	accumulation := self.calculateGlobalRewardRatioAccumulation(currentTime, stakedLiquidity)
366
367	return accumulation, stakedLiquidity
368}
369
370// updateGlobalRewardRatioAccumulation updates the global reward ratio accumulation and returns the new accumulation.
371func (self *PoolResolver) updateGlobalRewardRatioAccumulation(currentTime int64, currentStakedLiquidity *u256.Uint) *u256.Uint {
372	newAcc := self.calculateGlobalRewardRatioAccumulation(currentTime, currentStakedLiquidity)
373
374	// Persist as string to reduce stored object complexity.
375	self.Pool.SetGlobalRewardRatioAccumulationAt(currentTime, newAcc.ToString())
376	return newAcc
377}
378
379// RewardStateOf initializes a new RewardState for the given deposit, allocating reward and penalty slots for each warmup.
380//
381// Parameters:
382//   - deposit: Staked deposit whose pool, liquidity, and warmup schedule will be resolved.
383//
384// Returns:
385//   - state: RewardState initialized with zeroed per-warmup reward and penalty accumulators.
386func (self *PoolResolver) RewardStateOf(deposit *sr.Deposit) *RewardState {
387	warmups := len(deposit.Warmups())
388	result := &RewardState{
389		pool:      self,
390		deposit:   NewDepositResolver(deposit),
391		rewards:   make([]int64, warmups),
392		penalties: make([]int64, warmups),
393	}
394
395	return result
396}
397
398// reset clears cached rewards/penalties so a RewardState can be reused without re-allocating.
399func (self *RewardState) reset() {
400	for i := range self.rewards {
401		self.rewards[i] = 0
402		self.penalties[i] = 0
403	}
404}
405
406// NewPoolResolver wraps a pool's persisted reward, liquidity, tick, and incentive state for calculations.
407//
408// Parameters:
409//   - pool: Pool state to resolve.
410//
411// Returns:
412//   - resolver: Pool resolver backed by pool.
413func NewPoolResolver(pool *sr.Pool) *PoolResolver {
414	return &PoolResolver{
415		Pool: pool,
416	}
417}
418
419// newPoolResolverWithExit builds a resolver that reads the pool through an exit checkpoint.
420func newPoolResolverWithExit(pool *sr.Pool, exit *sr.UnstakedPosition) *PoolResolver {
421	return &PoolResolver{
422		Pool: pool,
423		exit: exit,
424	}
425}
426
427// RewardState is a struct for storing the intermediate state for reward calculation.
428type RewardState struct {
429	pool    *PoolResolver
430	deposit *DepositResolver
431
432	// accumulated rewards for each warmup
433	rewards   []int64
434	penalties []int64
435}
436
437// calculateInternalReward computes the position's per-warmup rewards and penalties from a pre-resolved
438// per-second reward-rate schedule (see PoolResolver.resolveInternalRewardSegments).
439//
440// It is pure: it neither queries pool tier/emission state nor writes any state, so the read-only view
441// path and the collect path use it identically. Each segment [start, end) is applied at its constant
442// per-second rate; rewardPerWarmup is a no-op for empty segments (start == end).
443func (self *RewardState) calculateInternalReward(segments []internalRewardSegment) ([]int64, []int64) {
444	for _, seg := range segments {
445		if err := self.rewardPerWarmup(seg.start, seg.end, seg.rewardPerSecond); err != nil {
446			panic(err)
447		}
448	}
449
450	self.applyWarmup()
451
452	return self.rewards, self.penalties
453}
454
455// updateExternalReward updates the external reward for the deposit.
456// It updates the last collect time for the external reward for the given incentive ID.
457// It returns an error if the current time is less than the last collect time for the external reward for the given incentive ID.
458func (self *RewardState) updateExternalReward(startTime, endTime int64, incentive *sr.ExternalIncentive) error {
459	lastCollectTime := self.deposit.ExternalRewardLastCollectTime(incentive.IncentiveId())
460	if startTime < lastCollectTime {
461		// This must not happen, but adding some guards just in case.
462		startTime = lastCollectTime
463	}
464
465	ictvStart := incentive.StartTimestamp()
466	if endTime < ictvStart {
467		return nil // Not started yet
468	}
469
470	if startTime < ictvStart {
471		startTime = ictvStart
472	}
473
474	ictvEnd := incentive.EndTimestamp()
475	if endTime > ictvEnd {
476		endTime = ictvEnd
477	}
478
479	if startTime > ictvEnd {
480		return nil // Already ended
481	}
482
483	return self.rewardPerWarmupX128(startTime, endTime, incentive.RewardPerSecondX128())
484}
485
486// calculateCollectableExternalReward calculates the calculated external reward for the deposit.
487// It calls updateExternalReward for the incentive period, applies warmup and returns the rewards and penalties.
488// used for reward calculation for a calculatable incentive
489func (self *RewardState) calculateCollectableExternalReward(startTime, endTime int64, incentive *sr.ExternalIncentive) int64 {
490	err := self.updateExternalReward(startTime, endTime, incentive)
491	if err != nil {
492		panic(err)
493	}
494
495	currentReward := u256.Zero()
496
497	for i := range self.rewards {
498		currentReward = currentReward.Add(currentReward, u256.NewUintFromInt64(self.rewards[i]))
499	}
500
501	return gnsmath.SafeConvertToInt64(currentReward)
502}
503
504// calculateExternalReward calculates the external reward for the deposit.
505// It calls rewardPerWarmup for startTime to endTime(clamped to the incentive period), applies warmup and returns the rewards and penalties.
506func (self *RewardState) calculateExternalReward(startTime, endTime int64, incentive *sr.ExternalIncentive) ([]int64, []int64) {
507	err := self.updateExternalReward(startTime, endTime, incentive)
508	if err != nil {
509		panic(err)
510	}
511
512	// apply warmup to collect rewards
513	self.applyWarmup()
514
515	return self.rewards, self.penalties
516}
517
518// applyWarmup applies the warmup to the rewards and calculate penalties.
519func (self *RewardState) applyWarmup() {
520	for i, warmup := range self.deposit.Warmups() {
521		warmupReward := self.rewards[i]
522
523		// calculate warmup reward applying warmup ratio
524		self.rewards[i] = gnsmath.SafeMulDivInt64(warmupReward, int64(warmup.WarmupRatio), 100)
525
526		// warmup penalty is the difference between the warmup reward and the warmup reward applying warmup ratio
527		self.penalties[i] = gnsmath.SafeSubInt64(warmupReward, self.rewards[i])
528	}
529}
530
531// rewardPerWarmup calculates the reward for each warmup, adds to the RewardState's rewards array.
532// Used by the internal reward path where rewardPerSecond is an int64 emission rate.
533func (self *RewardState) rewardPerWarmup(startTime, endTime int64, rewardPerSecond int64) error {
534	// Return early if startTime equals endTime to avoid unnecessary computation
535	if startTime == endTime {
536		return nil
537	}
538
539	startTick := self.pool.CurrentTick(startTime)
540	startRaw := self.pool.CalculateRawRewardForPosition(startTime, startTick, self.deposit.Deposit)
541
542	for i, warmup := range self.deposit.Warmups() {
543		if startTime >= warmup.NextWarmupTime {
544			// passed the warmup
545			continue
546		}
547
548		if endTime < warmup.NextWarmupTime {
549			endTick := self.pool.CurrentTick(endTime)
550			endRaw := self.pool.CalculateRawRewardForPosition(endTime, endTick, self.deposit.Deposit)
551			// Modular by design: boundary ticks created at different times give a
552			// wrapped base, so the borrow is expected and the wrapped difference
553			// is the true accumulation (Uniswap V3 subtracts unchecked too).
554			rewardAcc := u256.Zero().Sub(endRaw, startRaw)
555
556			rewardAcc, overflow := u256.Zero().MulOverflow(rewardAcc, self.deposit.Liquidity())
557			if overflow {
558				panic(errors.New(errOverflow))
559			}
560
561			rewardAcc = u256.MulDiv(rewardAcc, u256.NewUintFromInt64(rewardPerSecond), q128)
562			self.rewards[i] = gnsmath.SafeAddInt64(self.rewards[i], gnsmath.SafeConvertToInt64(rewardAcc))
563
564			break
565		}
566
567		endTick := self.pool.CurrentTick(warmup.NextWarmupTime)
568		endRaw := self.pool.CalculateRawRewardForPosition(warmup.NextWarmupTime, endTick, self.deposit.Deposit)
569		// See the note above: the subtraction is intentionally modular.
570		rewardAcc := u256.Zero().Sub(endRaw, startRaw)
571
572		rewardAcc, overflow := u256.Zero().MulOverflow(rewardAcc, self.deposit.Liquidity())
573		if overflow {
574			panic(errors.New(errOverflow))
575		}
576
577		rewardAcc = u256.MulDiv(rewardAcc, u256.NewUintFromInt64(rewardPerSecond), q128)
578		self.rewards[i] = gnsmath.SafeAddInt64(self.rewards[i], gnsmath.SafeConvertToInt64(rewardAcc))
579
580		startTime = warmup.NextWarmupTime
581		startTick = endTick
582		startRaw = endRaw
583	}
584
585	return nil
586}
587
588// rewardPerWarmupX128 calculates the reward for each warmup using a Q128-scaled
589// per-second rate. Used by the external incentive path; the per-second rate is
590// stored as `(rewardAmount << 128) / duration` in ExternalIncentive, so an
591// extra `>> 128` is needed after the standard `MulDiv(rewardAcc, rps, q128)`
592// to materialize the integer result.
593func (self *RewardState) rewardPerWarmupX128(startTime, endTime int64, rewardPerSecondX128 *u256.Uint) error {
594	if startTime == endTime {
595		return nil
596	}
597
598	startTick := self.pool.CurrentTick(startTime)
599	startRaw := self.pool.CalculateRawRewardForPosition(startTime, startTick, self.deposit.Deposit)
600
601	for i, warmup := range self.deposit.Warmups() {
602		if startTime >= warmup.NextWarmupTime {
603			continue
604		}
605
606		if endTime < warmup.NextWarmupTime {
607			endTick := self.pool.CurrentTick(endTime)
608			endRaw := self.pool.CalculateRawRewardForPosition(endTime, endTick, self.deposit.Deposit)
609			// Modular by design: boundary ticks created at different times give a
610			// wrapped base, so the borrow is expected and the wrapped difference
611			// is the true accumulation (Uniswap V3 subtracts unchecked too).
612			rewardAcc := u256.Zero().Sub(endRaw, startRaw)
613
614			rewardAcc, overflow := u256.Zero().MulOverflow(rewardAcc, self.deposit.Liquidity())
615			if overflow {
616				panic(errors.New(errOverflow))
617			}
618
619			rewardAcc = u256.MulDiv(rewardAcc, rewardPerSecondX128, q128)
620			rewardAcc = u256.Zero().Rsh(rewardAcc, 128)
621			self.rewards[i] = gnsmath.SafeAddInt64(self.rewards[i], gnsmath.SafeConvertToInt64(rewardAcc))
622
623			break
624		}
625
626		endTick := self.pool.CurrentTick(warmup.NextWarmupTime)
627		endRaw := self.pool.CalculateRawRewardForPosition(warmup.NextWarmupTime, endTick, self.deposit.Deposit)
628		// See the note above: the subtraction is intentionally modular.
629		rewardAcc := u256.Zero().Sub(endRaw, startRaw)
630
631		rewardAcc, overflow := u256.Zero().MulOverflow(rewardAcc, self.deposit.Liquidity())
632		if overflow {
633			panic(errors.New(errOverflow))
634		}
635
636		rewardAcc = u256.MulDiv(rewardAcc, rewardPerSecondX128, q128)
637		rewardAcc = u256.Zero().Rsh(rewardAcc, 128)
638		self.rewards[i] = gnsmath.SafeAddInt64(self.rewards[i], gnsmath.SafeConvertToInt64(rewardAcc))
639
640		startTime = warmup.NextWarmupTime
641		startTick = endTick
642		startRaw = endRaw
643	}
644
645	return nil
646}
647
648// modifyDeposit updates the pool's staked liquidity and returns the new staked liquidity.
649// updates when there is a change in the staked liquidity(tick cross, stake, unstake)
650func (self *PoolResolver) modifyDeposit(delta *i256.Int, currentTime int64, nextTick int32) *u256.Uint {
651	// update staker side pool info
652	lastStakedLiquidity := self.CurrentStakedLiquidity(currentTime)
653	deltaApplied := gnsmath.LiquidityMathAddDelta(lastStakedLiquidity, delta)
654	result := self.updateGlobalRewardRatioAccumulation(currentTime, lastStakedLiquidity)
655
656	// historical tick does NOT actually reflect the tick at the timestamp, but it provides correct ordering for the staked positions
657	// because TickCrossHook is assured to be called for the staked-initialized ticks
658	if self.isChangedTick(currentTime, nextTick) {
659		self.Pool.SetHistoricalTickAt(currentTime, nextTick)
660	}
661
662	switch deltaApplied.Sign() {
663	case -1:
664		panic("stakedLiquidity is less than 0, should not happen")
665	case 0:
666		if lastStakedLiquidity.Sign() == 1 {
667			// StakedLiquidity moved from positive to zero, start unclaimable period
668			self.startUnclaimablePeriod(currentTime)
669			self.IncentivesResolver().startUnclaimablePeriod(currentTime)
670		}
671	case 1:
672		if lastStakedLiquidity.Sign() == 0 {
673			// StakedLiquidity moved from zero to positive, end unclaimable period
674			self.endUnclaimablePeriod(currentTime)
675			self.IncentivesResolver().endUnclaimablePeriod(currentTime)
676		}
677	}
678
679	// Only append a staked-liquidity entry when the value actually changes (e.g. a tick cross whose
680	// net delta is zero leaves it unchanged). Unlike the global reward ratio accumulation, this tree
681	// carries no time-checkpoint semantics: it is read purely as a point-in-time value via
682	// CurrentStakedLiquidity (latest entry <= t), so omitting a duplicate-valued entry preserves
683	// behavior while keeping this append-only tree from growing on no-op updates.
684	if !lastStakedLiquidity.Eq(deltaApplied) {
685		self.Pool.SetStakedLiquidityAt(currentTime, deltaApplied)
686	}
687
688	return result
689}
690
691// startUnclaimablePeriod starts the unclaimable period.
692func (self *PoolResolver) startUnclaimablePeriod(currentTime int64) {
693	if self.LastUnclaimableTime() == 0 {
694		// We set only if it's the first time entering(0 indicates not set yet)
695		self.SetLastUnclaimableTime(currentTime)
696	}
697}
698
699// endUnclaimablePeriod ends the unclaimable period.
700// Accumulates to unclaimableAcc and resets lastUnclaimableTime to 0.
701func (self *PoolResolver) endUnclaimablePeriod(currentTime int64) {
702	if self.LastUnclaimableTime() == 0 {
703		// lastUnclaimableTime = 0 means tracking hasn't started yet
704		// This is normal during initial pool creation or when called from cacheReward
705		// during tier assignment with zero liquidity
706		return
707	}
708
709	self.updateUnclaimableAccumulateRewards(currentTime)
710	self.SetLastUnclaimableTime(0)
711}
712
713// updateUnclaimableAccumulateRewards ends the unclaimable period.
714// Accumulates to unclaimableAcc and resets lastUnclaimableTime to 0.
715func (self *PoolResolver) updateUnclaimableAccumulateRewards(currentTime int64) {
716	if self.LastUnclaimableTime() >= currentTime {
717		return
718	}
719
720	unclaimableDuration := gnsmath.SafeSubInt64(currentTime, self.LastUnclaimableTime())
721	currentUnclaimableReward := gnsmath.SafeMulInt64(unclaimableDuration, self.CurrentReward(self.LastUnclaimableTime()))
722	self.SetUnclaimableAcc(gnsmath.SafeAddInt64(self.UnclaimableAcc(), currentUnclaimableReward))
723}
724
725// processUnclaimableReward processes the unclaimable reward and returns the accumulated reward.
726// It resets unclaimableAcc to 0 and properly manages lastUnclaimableTime based on pool state.
727func (self *PoolResolver) processUnclaimableReward(endTime int64) int64 {
728	// Check current pool liquidity state
729	isZeroStakedLiquidity := self.CurrentStakedLiquidity(endTime).IsZero()
730
731	if self.LastUnclaimableTime() > 0 {
732		// We have an ongoing unclaimable period tracking
733		self.updateUnclaimableAccumulateRewards(endTime)
734
735		if isZeroStakedLiquidity {
736			// Still unclaimable - accumulate rewards up to endTime
737			// Update tracking time for continuing unclaimable period
738			self.SetLastUnclaimableTime(endTime)
739		} else {
740			// Was unclaimable but now has liquidity - properly end the period
741			self.SetLastUnclaimableTime(0)
742		}
743	} else {
744		if isZeroStakedLiquidity {
745			// No previous tracking but currently unclaimable - this shouldn't normally happen
746			// as startUnclaimablePeriod should have been called when liquidity reached 0
747			// Start tracking from now
748			self.SetLastUnclaimableTime(endTime)
749		}
750	}
751
752	// Return and reset accumulated unclaimable rewards
753	internalUnClaimable := self.UnclaimableAcc()
754	self.SetUnclaimableAcc(0)
755	return internalUnClaimable
756}
757
758// CalculateRawRewardForPosition calculates the theoretical reward accumulator for a position without debt or warmup adjustments.
759//
760// Parameters:
761//   - currentTime: Unix timestamp at which pool reward state is evaluated.
762//   - currentTick: Pool tick used to determine whether the position is below, inside, or above its range.
763//   - deposit: Position deposit whose liquidity and boundary ticks determine the raw reward.
764//
765// Returns:
766//   - reward: Q128-scaled raw reward accumulator for the position; debt, warmup ratios, and fees are not applied.
767func (self *PoolResolver) CalculateRawRewardForPosition(currentTime int64, currentTick int32, deposit *sr.Deposit) *u256.Uint {
768	var rewardAcc *u256.Uint
769
770	globalAcc := self.calculateGlobalRewardRatioAccumulation(currentTime, self.CurrentStakedLiquidity(currentTime))
771
772	lowerAcc := self.outsideAccumulationAt(deposit.TickLower(), currentTime)
773	upperAcc := self.outsideAccumulationAt(deposit.TickUpper(), currentTime)
774	if currentTick < deposit.TickLower() {
775		rewardAcc = u256.Zero().Sub(lowerAcc, upperAcc)
776	} else if currentTick >= deposit.TickUpper() {
777		rewardAcc = u256.Zero().Sub(upperAcc, lowerAcc)
778	} else {
779		rewardAcc = u256.Zero().Sub(globalAcc, lowerAcc)
780		rewardAcc = rewardAcc.Sub(rewardAcc, upperAcc)
781	}
782
783	return rewardAcc
784}