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_tick.gno

11.51 Kb · 318 lines
  1package staker
  2
  3import (
  4	"chain"
  5	"errors"
  6
  7	"gno.land/p/gnoswap/gnsmath/v1"
  8	i256 "gno.land/p/gnoswap/int256/v1"
  9	u256 "gno.land/p/gnoswap/uint256/v1"
 10	"gno.land/p/gnoswap/utils/v1"
 11	sr "gno.land/r/gnoswap/staker"
 12)
 13
 14type TickResolver struct {
 15	*sr.Tick
 16}
 17
 18// CurrentOutsideAccumulation returns the latest outside accumulation for the tick
 19// Parameters:
 20//   - timestamp: Timestamp through which the tick's outside-accumulation history is queried.
 21//
 22// Returns:
 23//   - *u256.Uint: Most recent decoded outside accumulation at or before timestamp, or zero when none exists.
 24func (self *TickResolver) CurrentOutsideAccumulation(timestamp int64) *u256.Uint {
 25	var acc *u256.Uint
 26	self.OutsideAccumulation().ReverseIterate(0, timestamp, func(key int64, value any) bool {
 27		v, ok := value.(string)
 28		if !ok {
 29			panic("failed to cast value to string")
 30		}
 31		acc = utils.DecodeUint256(v)
 32		return true
 33	})
 34	if acc == nil {
 35		return u256.Zero()
 36	}
 37	return acc
 38}
 39
 40// modifyDepositLower updates the tick's liquidity info by treating the deposit as a lower tick
 41func (self *TickResolver) modifyDepositLower(currentTime int64, liquidity *i256.Int) {
 42	// update staker side tick info
 43	self.SetStakedLiquidityGross(gnsmath.LiquidityMathAddDelta(self.StakedLiquidityGross(), liquidity))
 44	if self.StakedLiquidityGross().Lt(u256.Zero()) {
 45		panic("stakedLiquidityGross is negative")
 46	}
 47	self.SetStakedLiquidityDelta(i256.Zero().Add(self.StakedLiquidityDelta(), liquidity))
 48}
 49
 50// modifyDepositUpper updates the tick's liquidity info by treating the deposit as an upper tick
 51func (self *TickResolver) modifyDepositUpper(currentTime int64, liquidity *i256.Int) {
 52	self.SetStakedLiquidityGross(gnsmath.LiquidityMathAddDelta(self.StakedLiquidityGross(), liquidity))
 53	if self.StakedLiquidityGross().Lt(u256.Zero()) {
 54		panic("stakedLiquidityGross is negative")
 55	}
 56	self.SetStakedLiquidityDelta(i256.Zero().Sub(self.StakedLiquidityDelta(), liquidity))
 57}
 58
 59// updateCurrentOutsideAccumulation updates the tick's outside accumulation
 60// It "flips" the accumulation's inside/outside by subtracting the current outside accumulation from the global accumulation.
 61// An unchanged result does not require a checkpoint because predecessor queries
 62// return the same value for this and all subsequent timestamps.
 63func (self *TickResolver) updateCurrentOutsideAccumulation(timestamp int64, acc *u256.Uint) *u256.Uint {
 64	currentOutsideAccumulation := self.CurrentOutsideAccumulation(timestamp)
 65	newOutsideAccumulation := u256.Zero().Sub(acc, currentOutsideAccumulation)
 66	if newOutsideAccumulation.Eq(currentOutsideAccumulation) {
 67		return currentOutsideAccumulation
 68	}
 69
 70	self.SetOutsideAccumulationAt(timestamp, newOutsideAccumulation)
 71	return newOutsideAccumulation
 72}
 73
 74// NewTickResolver wraps a staker tick to expose reward-accumulation updates.
 75//
 76// Parameters:
 77//   - tick: Staker tick whose state is resolved.
 78//
 79// Returns:
 80//   - *TickResolver: Resolver backed by tick.
 81func NewTickResolver(tick *sr.Tick) *TickResolver {
 82	return &TickResolver{
 83		Tick: tick,
 84	}
 85}
 86
 87// swapStartHook is called when a swap starts
 88// This hook initializes the batch processor for accumulating tick crosses
 89func (s *stakerV1) swapStartHook(_ int, rlm realm, poolPath string, timestamp int64) {
 90	pool, ok := s.getPools().Get(poolPath)
 91	if !ok {
 92		return
 93	}
 94	if pool.Ticks().Tree().Size() == 0 {
 95		return
 96	}
 97
 98	// Initialize batch processor for this swap
 99	// This will accumulate all tick crosses until swap completion
100	currentSwapBatch := sr.NewSwapBatchProcessor(poolPath, pool, timestamp)
101	err := s.store.SetCurrentSwapBatch(0, rlm, currentSwapBatch)
102	if err != nil {
103		panic(err)
104	}
105}
106
107// swapEndHook is called when a swap ends
108// This hook processes all accumulated tick crosses in a single batch operation
109// and cleans up the batch processor. The batch processing approach provides:
110// 1. O(1) pool state updates instead of O(n) where n = number of tick crosses
111// 2. Reduced computational overhead for reward calculations
112// 3. Atomic processing ensuring consistency across all tick updates
113func (s *stakerV1) swapEndHook(_ int, rlm realm, poolPath string) error {
114	// Validate batch processor state
115	currentSwapBatch := s.store.GetCurrentSwapBatch()
116
117	if currentSwapBatch == nil || !currentSwapBatch.IsActive() || currentSwapBatch.PoolPath() != poolPath {
118		return nil
119	}
120
121	// Disable further accumulation
122	currentSwapBatch.SetIsActive(false)
123
124	// Process all accumulated tick crosses in a single batch
125	// This is where the optimization happens - instead of processing
126	// each tick cross individually, we calculate cumulative effects
127	err := s.processBatchedTickCrosses(0, rlm)
128	if err != nil {
129		return err
130	}
131
132	// Clean up batch processor
133	err = s.store.SetCurrentSwapBatch(0, rlm, nil)
134	if err != nil {
135		return err
136	}
137
138	return nil
139}
140
141// tickCrossHook is called when a tick is crossed.
142// Active swaps accumulate crosses and process their net liquidity change once at
143// swap end. Calls without an active batch use the immediate fallback, which
144// materializes the reward cache for each individual tick cross.
145func (s *stakerV1) tickCrossHook(_ int, rlm realm, poolPath string, tickId int32, zeroForOne bool, timestamp int64) {
146	pool, ok := s.getPools().Get(poolPath)
147	if !ok {
148		return
149	}
150
151	// Skip ticks without staking state.
152	tick := pool.Ticks().Get(tickId)
153	if tick == nil {
154		return
155	}
156
157	// Skip ticks without staked boundary liquidity (no reward impact)
158	if tick.StakedLiquidityGross().IsZero() {
159		return
160	}
161
162	currentSwapBatch := s.store.GetCurrentSwapBatch()
163	// Batch processing path: accumulate tick crosses during active swap
164	if currentSwapBatch != nil && currentSwapBatch.IsActive() && currentSwapBatch.PoolPath() == poolPath {
165		// Pre-calculate liquidity delta with direction consideration
166		// zeroForOne swap: liquidity delta is negated (liquidity being removed from current tick)
167		liquidityDelta := tick.StakedLiquidityDelta()
168		if zeroForOne {
169			liquidityDelta = i256.Zero().Neg(liquidityDelta)
170		}
171
172		// Accumulate this tick cross for batch processing
173		currentSwapBatch.AddCross(sr.NewSwapTickCross(tickId, zeroForOne, liquidityDelta))
174		return
175	}
176
177	// Immediate fallback for tick-cross callers without an active swap batch.
178	// Current production tick crosses originate from Swap and use batching; this
179	// path preserves correct accounting for future non-swap callers.
180	s.processTickCrossImmediate(pool, tick, tickId, zeroForOne, timestamp)
181}
182
183// processTickCrossImmediate processes one unbatched tick cross.
184// It materializes elapsed halving boundaries while the pre-cross liquidity is
185// still effective, before modifying the deposit for that individual cross.
186func (s *stakerV1) processTickCrossImmediate(pool *sr.Pool, tick *sr.Tick, tickId int32, zeroForOne bool, timestamp int64) {
187	// Calculate the effective tick position after crossing
188	// For zeroForOne swaps, liquidity becomes effective one tick lower
189	nextTick := tickId
190	if zeroForOne {
191		nextTick-- // Move to the lower tick where liquidity becomes active
192	}
193
194	// Calculate liquidity delta with direction consideration
195	liquidityDelta := tick.StakedLiquidityDelta()
196	if zeroForOne {
197		// Negate delta for zeroForOne direction (liquidity being removed from current range)
198		liquidityDelta = i256.Zero().Neg(liquidityDelta)
199	}
200
201	// Update pool's cumulative deposit with the liquidity change
202	poolResolver := NewPoolResolver(pool)
203	s.getPoolTier().cacheRewardForPool(timestamp, s.getPools(), pool.PoolPath())
204	newAcc := poolResolver.modifyDeposit(liquidityDelta, timestamp, nextTick)
205
206	// Update the tick's outside accumulation for reward calculations
207	// This ensures proper reward distribution tracking across tick boundaries
208	tickResolver := NewTickResolver(tick)
209	tickResolver.updateCurrentOutsideAccumulation(timestamp, newAcc)
210}
211
212// processBatchedTickCrosses processes all accumulated tick crosses at once
213// This is the core optimization function that processes multiple tick crosses in a single operation.
214// Instead of updating pool state for each tick cross individually (O(n) operations),
215// it calculates the cumulative effect and applies it once (O(1) pool updates + O(n) tick updates).
216func (s *stakerV1) processBatchedTickCrosses(_ int, rlm realm) error {
217	// Early exit for empty batches
218	currentSwapBatch := s.store.GetCurrentSwapBatch()
219	if currentSwapBatch == nil || len(currentSwapBatch.Crosses()) == 0 {
220		return nil
221	}
222
223	// Validate pool reference
224	if currentSwapBatch.Pool() == nil {
225		return errors.New(errPoolNotFound)
226	}
227
228	batch := currentSwapBatch
229	timestamp := batch.Timestamp()
230
231	// Phase 1: Calculate cumulative liquidity delta across all tick crosses
232	// This replaces multiple individual pool updates with a single cumulative update
233	cumulativeDelta := i256.Zero()
234	for _, tickCross := range batch.Crosses() {
235		newDelta := cumulativeDelta.Add(cumulativeDelta, tickCross.Delta())
236		cumulativeDelta = newDelta
237	}
238
239	// Phase 2: Determine the effective tick position for pool state update
240	// Use the last crossed tick as the reference point for cumulative changes
241	lastCross := batch.LastCross()
242	if lastCross == nil {
243		return nil
244	}
245
246	lastTick := lastCross.TickID()
247	if lastCross.ZeroForOne() {
248		lastTick-- // Adjust for zeroForOne direction
249	}
250
251	// Phase 3: Apply the batch's cumulative change to pool state once.
252	// cacheRewardForPool runs once for each non-empty normal swap batch, before
253	// the net liquidity change. It sees the pre-cross liquidity and splits
254	// unclaimable intervals at every elapsed halving boundary.
255	poolResolver := NewPoolResolver(batch.Pool())
256	s.getPoolTier().cacheRewardForPool(timestamp, s.getPools(), batch.PoolPath())
257	newAcc := poolResolver.modifyDeposit(cumulativeDelta, timestamp, lastTick)
258
259	// Phase 4: Update individual tick outside accumulations for reward tracking
260	// While we optimize pool updates, each tick still needs its accumulation updated
261	// for proper reward distribution calculations
262
263	for _, tickCross := range batch.Crosses() {
264		tick := batch.Pool().Ticks().Get(tickCross.TickID())
265		if tick == nil {
266			// Pruned after the cross was accumulated, so its staked gross
267			// liquidity is zero and it carries no reward weight.
268			continue
269		}
270
271		tickResolver := NewTickResolver(tick)
272		outsideAccumulation := tickResolver.updateCurrentOutsideAccumulation(timestamp, newAcc)
273
274		tickCrossEventInfo := NewTickCrossEventInfo(
275			tickCross.TickID(),
276			tick.StakedLiquidityGross(),
277			tick.StakedLiquidityDelta(),
278			outsideAccumulation,
279		)
280
281		chain.Emit(
282			"StakerTickCross",
283			"poolPath", batch.PoolPath(),
284			"tick", tickCrossEventInfo.ToString(),
285		)
286	}
287
288	previousRealm := rlm.Previous()
289	stakedLiquidity := poolResolver.CurrentStakedLiquidity(timestamp)
290
291	// Emit event with staker-side tick cross information.
292	// lastTick — the effective tick written to HistoricalTick by modifyDeposit above.
293	// Reward calculation (CalculateRawRewardForPosition) reads HistoricalTick for the feeGrowthInside branch,
294	// so off-chain indexers must use this same value (NOT the pool's Slot0 tick) to reproduce in-range status.
295	chain.Emit(
296		"BatchStakerTickCross",
297		"prevAddr", previousRealm.Address().String(),
298		"prevRealm", previousRealm.PkgPath(),
299		"poolPath", batch.PoolPath(),
300		"blockTimestamp", utils.FormatInt(timestamp),
301		"stakedLiquidity", stakedLiquidity.ToString(),
302		"globalRewardRatioAccX128", newAcc.ToString(),
303		"lastTick", utils.FormatInt(lastTick),
304	)
305
306	return nil
307}
308
309func (s *stakerV1) setupSwapHooks(_ int, rlm realm) {
310	// Set tick cross hook for pool contract
311	s.poolAccessor.SetTickCrossHook(0, rlm, s.tickCrossHook)
312
313	// Set swap start/end hooks for batch processing
314	s.poolAccessor.SetSwapStartHook(0, rlm, s.swapStartHook)
315
316	// Set swap end hook for batch processing
317	s.poolAccessor.SetSwapEndHook(0, rlm, s.swapEndHook)
318}