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