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}