package staker import ( "chain" "chain/runtime" "time" bptree "gno.land/p/nt/bptree/v0" ufmt "gno.land/p/nt/ufmt/v0" "gno.land/p/gnoswap/gnsmath/v1" "gno.land/p/gnoswap/utils/v1" sr "gno.land/r/gnoswap/staker" ) // UnstakedPositions manages exit checkpoints. type UnstakedPositions struct { tree *bptree.BPTree } // NewUnstakedPositions creates a new UnstakedPositions instance. // // Returns: // - *UnstakedPositions: Empty exit-checkpoint manager backed by a new B+ tree. func NewUnstakedPositions() *UnstakedPositions { return &UnstakedPositions{ tree: sr.NewBPTreeN(16), // positionId -> *UnstakedPosition } } // Has checks if a position ID has an exit checkpoint. // // Parameters: // - positionId: Position ID whose exit-checkpoint membership is checked. // // Returns: // - bool: true when an exit checkpoint exists for positionId; false otherwise. func (self *UnstakedPositions) Has(positionId uint64) bool { return self.tree.Has(utils.EncodeUint64(positionId)) } // get retrieves an exit checkpoint by position ID, or nil when there is none. func (self *UnstakedPositions) get(positionId uint64) *sr.UnstakedPosition { value := self.tree.Get(utils.EncodeUint64(positionId)) if value == nil { return nil } position, ok := value.(*sr.UnstakedPosition) if !ok { panic(ufmt.Sprintf("failed to cast value to *UnstakedPosition: %T", value)) } return position } // set stores an exit checkpoint for a position ID. func (self *UnstakedPositions) set(positionId uint64, position *sr.UnstakedPosition) { self.tree.Set(utils.EncodeUint64(positionId), position) } // remove deletes an exit checkpoint by position ID. func (self *UnstakedPositions) remove(positionId uint64) { self.tree.Remove(utils.EncodeUint64(positionId)) } // getUnstakedPositions returns the exit checkpoints backed by the store. func (s *stakerV1) getUnstakedPositions() *UnstakedPositions { return &UnstakedPositions{tree: s.store.GetUnstakedPositions()} } // uncollectedIncentiveCountOf returns how many checkpoints still owe the incentive. // // The count is per incentive, not per pool: one checkpoint that can never be funded would // otherwise lock the refunds of every other incentive in the same pool. func (s *stakerV1) uncollectedIncentiveCountOf(incentiveId string) int64 { value := s.store.GetUncollectedIncentiveCounts().Get(incentiveId) if value == nil { return 0 } count, ok := value.(int64) if !ok { panic(ufmt.Sprintf("failed to cast uncollected incentive count to int64: %T", value)) } return count } // addUncollectedIncentiveCount moves the incentive's count by delta, dropping the entry at zero. func (s *stakerV1) addUncollectedIncentiveCount(incentiveId string, delta int64) { addUncollectedIncentiveCountTo(s.store.GetUncollectedIncentiveCounts(), incentiveId, delta) } func addUncollectedIncentiveCountTo(counts *bptree.BPTree, incentiveId string, delta int64) { count := int64(0) if value := counts.Get(incentiveId); value != nil { casted, ok := value.(int64) if !ok { panic(ufmt.Sprintf("failed to cast uncollected incentive count to int64: %T", value)) } count = casted } next := gnsmath.SafeAddInt64(count, delta) if next < 0 { panic(makeErrorWithDetails( errCalculationError, ufmt.Sprintf("uncollected count of incentive(%s) went negative", incentiveId), )) } if next == 0 { counts.Remove(incentiveId) return } counts.Set(incentiveId, next) } // recordUnstakedPosition writes the exit checkpoint before the teardown touches the pool. // The deposit's incentive index is brought up to date first, so the checkpoint owes exactly // the incentives the position accrued from. func (s *stakerV1) recordUnstakedPosition(_ int, rlm realm, positionId uint64, deposit *sr.Deposit, exitTime int64) *sr.UnstakedPosition { poolPath := deposit.TargetPoolPath() pool, ok := s.getPools().Get(poolPath) if !ok { panic(makeErrorWithDetails( errDataNotFound, ufmt.Sprintf("pool(%s) does not exist", poolPath), )) } poolResolver := NewPoolResolver(pool) depositResolver := NewDepositResolver(deposit) lastUpdatedAt := depositResolver.LastExternalIncentiveUpdatedAt() if lastUpdatedAt < exitTime { poolResolver.IncentivesResolver().IterateIncentiveIdsByTime(lastUpdatedAt, exitTime, func(incentiveId string) bool { deposit.AddExternalIncentiveId(incentiveId) return false }) deposit.SetLastExternalIncentiveUpdatedAt(exitTime) } tier, tierRatio, tierCount, err := s.getPoolTier().tierContextOf(poolPath) if err != nil { panic(err) } lowerTick := poolResolver.GetOrNewTick(deposit.TickLower()) upperTick := poolResolver.GetOrNewTick(deposit.TickUpper()) incentiveIds := deposit.GetExternalIncentiveIdList() position := sr.NewUnstakedPosition( deposit, exitTime, poolResolver.CurrentTick(exitTime), lowerTick, upperTick, NewTickResolver(lowerTick).CurrentOutsideAccumulation(exitTime).ToString(), NewTickResolver(upperTick).CurrentOutsideAccumulation(exitTime).ToString(), tier, tierRatio, tierCount, s.GetUnstakingFee(), incentiveIds, ) s.getUnstakedPositions().set(positionId, position) counts := s.store.GetUncollectedIncentiveCounts() for _, incentiveId := range incentiveIds { addUncollectedIncentiveCountTo(counts, incentiveId, 1) } return position } // dropUnstakedPositionIfCollected removes the checkpoint once every reward source is collected. func (s *stakerV1) dropUnstakedPositionIfCollected(_ int, rlm realm, target *collectTarget) { if !target.checkpoint.FullyCollected() { return } s.getUnstakedPositions().remove(target.positionId) previousRealm := rlm.Previous() chain.Emit( "ClearUnstakedPosition", "prevAddr", previousRealm.Address().String(), "prevRealm", previousRealm.PkgPath(), "positionId", utils.FormatUint(target.positionId), "poolPath", target.deposit.TargetPoolPath(), "exitTime", utils.FormatInt(target.checkpoint.ExitTime()), "currentTime", utils.FormatInt(time.Now().Unix()), "currentHeight", utils.FormatInt(runtime.ChainHeight()), ) } // HasUnstakedPosition reports whether a position is staked no longer but still has uncollected rewards. // // Parameters: // - positionId: Position ID whose exit-checkpoint membership is checked. // // Returns: // - bool: true when positionId has an exit checkpoint; false otherwise. func (s *stakerV1) HasUnstakedPosition(positionId uint64) bool { return s.getUnstakedPositions().Has(positionId) } // GetUnstakedPositionExitTime returns when an unstaked position stopped accruing rewards. // // Parameters: // - positionId: Position ID whose exit checkpoint is queried. // // Returns: // - int64: Exit timestamp in Unix seconds. // - error: Non-nil when positionId has no uncollected exit checkpoint; nil on success. func (s *stakerV1) GetUnstakedPositionExitTime(positionId uint64) (int64, error) { position := s.getUnstakedPositions().get(positionId) if position == nil { return 0, ufmt.Errorf("%v: positionId(%d) has no uncollected reward", errDataNotFound, positionId) } return position.ExitTime(), nil } // GetUnstakedPositionPendingIncentives returns the incentives an unstaked position has yet to // collect. // // Parameters: // - positionId: Position ID whose pending incentive IDs are queried. // // Returns: // - []string: External incentive IDs with rewards still pending for the checkpoint. // - error: Non-nil when positionId has no uncollected exit checkpoint; nil on success. func (s *stakerV1) GetUnstakedPositionPendingIncentives(positionId uint64) ([]string, error) { position := s.getUnstakedPositions().get(positionId) if position == nil { return nil, ufmt.Errorf("%v: positionId(%d) has no uncollected reward", errDataNotFound, positionId) } return position.PendingIncentiveIdList(), nil } // GetUncollectedIncentiveCount returns how many unstaked positions still owe a reward from the // incentive; EndExternalIncentive is refused while it is non-zero. // // Parameters: // - incentiveId: External incentive identifier whose outstanding checkpoint count is queried. // // Returns: // - int64: Number of exit checkpoints still owing incentiveId; zero permits incentive finalization. func (s *stakerV1) GetUncollectedIncentiveCount(incentiveId string) int64 { return s.uncollectedIncentiveCountOf(incentiveId) }