package staker import ( "chain" "errors" "gno.land/p/gnoswap/gnsmath/v1" i256 "gno.land/p/gnoswap/int256/v1" u256 "gno.land/p/gnoswap/uint256/v1" "gno.land/p/gnoswap/utils/v1" sr "gno.land/r/gnoswap/staker" ) type TickResolver struct { *sr.Tick } // CurrentOutsideAccumulation returns the latest outside accumulation for the tick // Parameters: // - timestamp: Timestamp through which the tick's outside-accumulation history is queried. // // Returns: // - *u256.Uint: Most recent decoded outside accumulation at or before timestamp, or zero when none exists. func (self *TickResolver) CurrentOutsideAccumulation(timestamp int64) *u256.Uint { var acc *u256.Uint self.OutsideAccumulation().ReverseIterate(0, timestamp, func(key int64, value any) bool { v, ok := value.(string) if !ok { panic("failed to cast value to string") } acc = utils.DecodeUint256(v) return true }) if acc == nil { return u256.Zero() } return acc } // modifyDepositLower updates the tick's liquidity info by treating the deposit as a lower tick func (self *TickResolver) modifyDepositLower(currentTime int64, liquidity *i256.Int) { // update staker side tick info self.SetStakedLiquidityGross(gnsmath.LiquidityMathAddDelta(self.StakedLiquidityGross(), liquidity)) if self.StakedLiquidityGross().Lt(u256.Zero()) { panic("stakedLiquidityGross is negative") } self.SetStakedLiquidityDelta(i256.Zero().Add(self.StakedLiquidityDelta(), liquidity)) } // modifyDepositUpper updates the tick's liquidity info by treating the deposit as an upper tick func (self *TickResolver) modifyDepositUpper(currentTime int64, liquidity *i256.Int) { self.SetStakedLiquidityGross(gnsmath.LiquidityMathAddDelta(self.StakedLiquidityGross(), liquidity)) if self.StakedLiquidityGross().Lt(u256.Zero()) { panic("stakedLiquidityGross is negative") } self.SetStakedLiquidityDelta(i256.Zero().Sub(self.StakedLiquidityDelta(), liquidity)) } // updateCurrentOutsideAccumulation updates the tick's outside accumulation // It "flips" the accumulation's inside/outside by subtracting the current outside accumulation from the global accumulation. // An unchanged result does not require a checkpoint because predecessor queries // return the same value for this and all subsequent timestamps. func (self *TickResolver) updateCurrentOutsideAccumulation(timestamp int64, acc *u256.Uint) *u256.Uint { currentOutsideAccumulation := self.CurrentOutsideAccumulation(timestamp) newOutsideAccumulation := u256.Zero().Sub(acc, currentOutsideAccumulation) if newOutsideAccumulation.Eq(currentOutsideAccumulation) { return currentOutsideAccumulation } self.SetOutsideAccumulationAt(timestamp, newOutsideAccumulation) return newOutsideAccumulation } // NewTickResolver wraps a staker tick to expose reward-accumulation updates. // // Parameters: // - tick: Staker tick whose state is resolved. // // Returns: // - *TickResolver: Resolver backed by tick. func NewTickResolver(tick *sr.Tick) *TickResolver { return &TickResolver{ Tick: tick, } } // swapStartHook is called when a swap starts // This hook initializes the batch processor for accumulating tick crosses func (s *stakerV1) swapStartHook(_ int, rlm realm, poolPath string, timestamp int64) { pool, ok := s.getPools().Get(poolPath) if !ok { return } if pool.Ticks().Tree().Size() == 0 { return } // Initialize batch processor for this swap // This will accumulate all tick crosses until swap completion currentSwapBatch := sr.NewSwapBatchProcessor(poolPath, pool, timestamp) err := s.store.SetCurrentSwapBatch(0, rlm, currentSwapBatch) if err != nil { panic(err) } } // swapEndHook is called when a swap ends // This hook processes all accumulated tick crosses in a single batch operation // and cleans up the batch processor. The batch processing approach provides: // 1. O(1) pool state updates instead of O(n) where n = number of tick crosses // 2. Reduced computational overhead for reward calculations // 3. Atomic processing ensuring consistency across all tick updates func (s *stakerV1) swapEndHook(_ int, rlm realm, poolPath string) error { // Validate batch processor state currentSwapBatch := s.store.GetCurrentSwapBatch() if currentSwapBatch == nil || !currentSwapBatch.IsActive() || currentSwapBatch.PoolPath() != poolPath { return nil } // Disable further accumulation currentSwapBatch.SetIsActive(false) // Process all accumulated tick crosses in a single batch // This is where the optimization happens - instead of processing // each tick cross individually, we calculate cumulative effects err := s.processBatchedTickCrosses(0, rlm) if err != nil { return err } // Clean up batch processor err = s.store.SetCurrentSwapBatch(0, rlm, nil) if err != nil { return err } return nil } // tickCrossHook is called when a tick is crossed. // Active swaps accumulate crosses and process their net liquidity change once at // swap end. Calls without an active batch use the immediate fallback, which // materializes the reward cache for each individual tick cross. func (s *stakerV1) tickCrossHook(_ int, rlm realm, poolPath string, tickId int32, zeroForOne bool, timestamp int64) { pool, ok := s.getPools().Get(poolPath) if !ok { return } // Skip ticks without staking state. tick := pool.Ticks().Get(tickId) if tick == nil { return } // Skip ticks without staked boundary liquidity (no reward impact) if tick.StakedLiquidityGross().IsZero() { return } currentSwapBatch := s.store.GetCurrentSwapBatch() // Batch processing path: accumulate tick crosses during active swap if currentSwapBatch != nil && currentSwapBatch.IsActive() && currentSwapBatch.PoolPath() == poolPath { // Pre-calculate liquidity delta with direction consideration // zeroForOne swap: liquidity delta is negated (liquidity being removed from current tick) liquidityDelta := tick.StakedLiquidityDelta() if zeroForOne { liquidityDelta = i256.Zero().Neg(liquidityDelta) } // Accumulate this tick cross for batch processing currentSwapBatch.AddCross(sr.NewSwapTickCross(tickId, zeroForOne, liquidityDelta)) return } // Immediate fallback for tick-cross callers without an active swap batch. // Current production tick crosses originate from Swap and use batching; this // path preserves correct accounting for future non-swap callers. s.processTickCrossImmediate(pool, tick, tickId, zeroForOne, timestamp) } // processTickCrossImmediate processes one unbatched tick cross. // It materializes elapsed halving boundaries while the pre-cross liquidity is // still effective, before modifying the deposit for that individual cross. func (s *stakerV1) processTickCrossImmediate(pool *sr.Pool, tick *sr.Tick, tickId int32, zeroForOne bool, timestamp int64) { // Calculate the effective tick position after crossing // For zeroForOne swaps, liquidity becomes effective one tick lower nextTick := tickId if zeroForOne { nextTick-- // Move to the lower tick where liquidity becomes active } // Calculate liquidity delta with direction consideration liquidityDelta := tick.StakedLiquidityDelta() if zeroForOne { // Negate delta for zeroForOne direction (liquidity being removed from current range) liquidityDelta = i256.Zero().Neg(liquidityDelta) } // Update pool's cumulative deposit with the liquidity change poolResolver := NewPoolResolver(pool) s.getPoolTier().cacheRewardForPool(timestamp, s.getPools(), pool.PoolPath()) newAcc := poolResolver.modifyDeposit(liquidityDelta, timestamp, nextTick) // Update the tick's outside accumulation for reward calculations // This ensures proper reward distribution tracking across tick boundaries tickResolver := NewTickResolver(tick) tickResolver.updateCurrentOutsideAccumulation(timestamp, newAcc) } // processBatchedTickCrosses processes all accumulated tick crosses at once // This is the core optimization function that processes multiple tick crosses in a single operation. // Instead of updating pool state for each tick cross individually (O(n) operations), // it calculates the cumulative effect and applies it once (O(1) pool updates + O(n) tick updates). func (s *stakerV1) processBatchedTickCrosses(_ int, rlm realm) error { // Early exit for empty batches currentSwapBatch := s.store.GetCurrentSwapBatch() if currentSwapBatch == nil || len(currentSwapBatch.Crosses()) == 0 { return nil } // Validate pool reference if currentSwapBatch.Pool() == nil { return errors.New(errPoolNotFound) } batch := currentSwapBatch timestamp := batch.Timestamp() // Phase 1: Calculate cumulative liquidity delta across all tick crosses // This replaces multiple individual pool updates with a single cumulative update cumulativeDelta := i256.Zero() for _, tickCross := range batch.Crosses() { newDelta := cumulativeDelta.Add(cumulativeDelta, tickCross.Delta()) cumulativeDelta = newDelta } // Phase 2: Determine the effective tick position for pool state update // Use the last crossed tick as the reference point for cumulative changes lastCross := batch.LastCross() if lastCross == nil { return nil } lastTick := lastCross.TickID() if lastCross.ZeroForOne() { lastTick-- // Adjust for zeroForOne direction } // Phase 3: Apply the batch's cumulative change to pool state once. // cacheRewardForPool runs once for each non-empty normal swap batch, before // the net liquidity change. It sees the pre-cross liquidity and splits // unclaimable intervals at every elapsed halving boundary. poolResolver := NewPoolResolver(batch.Pool()) s.getPoolTier().cacheRewardForPool(timestamp, s.getPools(), batch.PoolPath()) newAcc := poolResolver.modifyDeposit(cumulativeDelta, timestamp, lastTick) // Phase 4: Update individual tick outside accumulations for reward tracking // While we optimize pool updates, each tick still needs its accumulation updated // for proper reward distribution calculations for _, tickCross := range batch.Crosses() { tick := batch.Pool().Ticks().Get(tickCross.TickID()) if tick == nil { // Pruned after the cross was accumulated, so its staked gross // liquidity is zero and it carries no reward weight. continue } tickResolver := NewTickResolver(tick) outsideAccumulation := tickResolver.updateCurrentOutsideAccumulation(timestamp, newAcc) tickCrossEventInfo := NewTickCrossEventInfo( tickCross.TickID(), tick.StakedLiquidityGross(), tick.StakedLiquidityDelta(), outsideAccumulation, ) chain.Emit( "StakerTickCross", "poolPath", batch.PoolPath(), "tick", tickCrossEventInfo.ToString(), ) } previousRealm := rlm.Previous() stakedLiquidity := poolResolver.CurrentStakedLiquidity(timestamp) // Emit event with staker-side tick cross information. // lastTick — the effective tick written to HistoricalTick by modifyDeposit above. // Reward calculation (CalculateRawRewardForPosition) reads HistoricalTick for the feeGrowthInside branch, // so off-chain indexers must use this same value (NOT the pool's Slot0 tick) to reproduce in-range status. chain.Emit( "BatchStakerTickCross", "prevAddr", previousRealm.Address().String(), "prevRealm", previousRealm.PkgPath(), "poolPath", batch.PoolPath(), "blockTimestamp", utils.FormatInt(timestamp), "stakedLiquidity", stakedLiquidity.ToString(), "globalRewardRatioAccX128", newAcc.ToString(), "lastTick", utils.FormatInt(lastTick), ) return nil } func (s *stakerV1) setupSwapHooks(_ int, rlm realm) { // Set tick cross hook for pool contract s.poolAccessor.SetTickCrossHook(0, rlm, s.tickCrossHook) // Set swap start/end hooks for batch processing s.poolAccessor.SetSwapStartHook(0, rlm, s.swapStartHook) // Set swap end hook for batch processing s.poolAccessor.SetSwapEndHook(0, rlm, s.swapEndHook) }