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

staker.gno

27.37 Kb · 842 lines
  1package staker
  2
  3import (
  4	"chain"
  5	"chain/runtime"
  6	"errors"
  7	"time"
  8
  9	bptree "gno.land/p/nt/bptree/v0"
 10	ufmt "gno.land/p/nt/ufmt/v0"
 11
 12	"gno.land/p/gnoswap/gnsmath"
 13	prbac "gno.land/p/gnoswap/rbac"
 14	"gno.land/p/gnoswap/utils"
 15
 16	"gno.land/r/gnoswap/access"
 17	_ "gno.land/r/gnoswap/rbac"
 18
 19	"gno.land/r/gnoswap/common"
 20	"gno.land/r/gnoswap/halt"
 21	sr "gno.land/r/gnoswap/staker"
 22
 23	"gno.land/r/gnoswap/gns"
 24
 25	en "gno.land/r/gnoswap/emission"
 26	pn "gno.land/r/gnoswap/position"
 27
 28	i256 "gno.land/p/gnoswap/int256"
 29	u256 "gno.land/p/gnoswap/uint256"
 30
 31	"gno.land/r/gnoswap/referral"
 32)
 33
 34const ZERO_ADDRESS = address("")
 35
 36// Deposits manages all staked positions.
 37type Deposits struct {
 38	tree *bptree.BPTree
 39}
 40
 41// NewDeposits creates a new Deposits instance.
 42func NewDeposits() *Deposits {
 43	return &Deposits{
 44		tree: sr.NewBPTreeN(16), // positionId -> *Deposit
 45	}
 46}
 47
 48// Has checks if a position ID exists in deposits.
 49func (self *Deposits) Has(positionId uint64) bool {
 50	return self.tree.Has(EncodeUint(positionId))
 51}
 52
 53// Iterate traverses deposits within the specified range.
 54func (self *Deposits) Iterate(start uint64, end uint64, fn func(positionId uint64, deposit *sr.Deposit) bool) {
 55	self.tree.Iterate(EncodeUint(start), EncodeUint(end), func(positionId string, depositI any) bool {
 56		dpst := retrieveDeposit(depositI)
 57		return fn(DecodeUint(positionId), dpst)
 58	})
 59}
 60
 61func (self *Deposits) IterateByPoolPath(start, end uint64, poolPath string, fn func(positionId uint64, deposit *sr.Deposit) bool) {
 62	self.tree.Iterate(EncodeUint(start), EncodeUint(end), func(positionId string, depositI any) bool {
 63		deposit := retrieveDeposit(depositI)
 64		if deposit.TargetPoolPath() != poolPath {
 65			return false
 66		}
 67
 68		return fn(DecodeUint(positionId), deposit)
 69	})
 70}
 71
 72// Size returns the number of deposits.
 73func (self *Deposits) Size() int {
 74	return self.tree.Size()
 75}
 76
 77// get retrieves a deposit by position ID.
 78func (self *Deposits) get(positionId uint64) *sr.Deposit {
 79	depositI := self.tree.Get(EncodeUint(positionId))
 80	if depositI == nil {
 81		panic(makeErrorWithDetails(
 82			errDataNotFound,
 83			ufmt.Sprintf("positionId(%d) not found", positionId),
 84		))
 85	}
 86	return retrieveDeposit(depositI)
 87}
 88
 89// retrieveDeposit safely casts data to Deposit type.
 90func retrieveDeposit(data any) *sr.Deposit {
 91	deposit, ok := data.(*sr.Deposit)
 92	if !ok {
 93		panic("failed to cast value to *Deposit")
 94	}
 95	return deposit
 96}
 97
 98// set stores a deposit for a position ID.
 99func (self *Deposits) set(positionId uint64, deposit *sr.Deposit) {
100	self.tree.Set(EncodeUint(positionId), deposit)
101}
102
103// remove deletes a deposit by position ID.
104func (self *Deposits) remove(positionId uint64) {
105	self.tree.Remove(EncodeUint(positionId))
106}
107
108// ExternalIncentives manages external incentive programs.
109type ExternalIncentives struct {
110	tree *bptree.BPTree
111}
112
113// NewExternalIncentives creates a new ExternalIncentives instance.
114func NewExternalIncentives() *ExternalIncentives {
115	return &ExternalIncentives{
116		tree: sr.NewBPTreeN(16),
117	}
118}
119
120// Has checks if an incentive ID exists.
121func (self *ExternalIncentives) Has(incentiveId string) bool { return self.tree.Has(incentiveId) }
122
123// Size returns the number of external incentives.
124func (self *ExternalIncentives) Size() int { return self.tree.Size() }
125
126// get retrieves an external incentive by ID.
127func (self *ExternalIncentives) get(incentiveId string) *sr.ExternalIncentive {
128	incentiveI := self.tree.Get(incentiveId)
129	if incentiveI == nil {
130		panic(makeErrorWithDetails(
131			errDataNotFound,
132			ufmt.Sprintf("incentiveId(%s) not found", incentiveId),
133		))
134	}
135
136	incentive, ok := incentiveI.(*sr.ExternalIncentive)
137	if !ok {
138		panic("failed to cast value to *ExternalIncentive")
139	}
140	return incentive
141}
142
143// set stores an external incentive.
144func (self *ExternalIncentives) set(incentiveId string, incentive *sr.ExternalIncentive) {
145	self.tree.Set(incentiveId, incentive)
146}
147
148// remove deletes an external incentive by ID.
149func (self *ExternalIncentives) remove(incentiveId string) {
150	self.tree.Remove(incentiveId)
151}
152
153// EmissionCacheUpdateHook updates the emission cache when called.
154// This follows the same pattern as other hooks in the staker contract.
155func (s *stakerV1) emissionCacheUpdateHook(_ int, rlm realm, emissionAmountPerSecond int64) {
156	poolTier := s.getPoolTier()
157	if poolTier != nil {
158		currentTime := time.Now().Unix()
159		pools := s.getPools()
160
161		// First cache the current rewards before updating emission
162		poolTier.cacheReward(currentTime, pools)
163
164		// Update the current emission cache with the latest value
165		poolTier.currentEmission = emissionAmountPerSecond
166
167		// Now apply the new emission rate to each pool individually
168		poolTier.applyCacheToAllPools(pools, currentTime, emissionAmountPerSecond)
169
170		s.updatePoolTier(0, rlm, poolTier)
171	}
172}
173
174// StakeToken stakes an LP position NFT to earn rewards.
175//
176// Transfers position NFT to staker and begins reward accumulation.
177// Eligible for internal incentives (GNS emission) and external rewards.
178// Position must have liquidity and be in eligible pool tier.
179//
180// Parameters:
181//   - positionId: LP position NFT token ID to stake
182//   - referrer: Optional referral address for tracking
183//
184// Returns:
185//   - poolPath: Pool identifier (token0:token1:fee)
186//
187// Requirements:
188//   - Caller must own the position NFT
189//   - Position must have active liquidity
190//   - Pool must be in tier 1, 2, or 3
191//   - Position not already staked
192//
193// Note: Out-of-range positions earn no rewards but can be staked.
194func (s *stakerV1) StakeToken(_ int, rlm realm, positionId uint64, referrer string) string {
195	if !rlm.IsCurrent() {
196		panic(errors.New(errSpoofedRealm))
197	}
198
199	halt.AssertIsNotHaltedStaker()
200
201	assertIsNotStaked(s, positionId)
202
203	en.MintAndDistributeGns(cross(rlm))
204
205	previousRealm := rlm.Previous()
206	caller := previousRealm.Address()
207	currentTime := time.Now().Unix()
208
209	owner := s.nftAccessor.MustOwnerOf(positionIdFrom(positionId))
210	assertIsPositionOwner(owner, caller)
211
212	actualReferrer := referral.TryRegister(cross(rlm), caller, referrer)
213
214	if err := tokenHasLiquidity(positionId); err != nil {
215		panic(err.Error())
216	}
217
218	// check pool path from positionId
219	poolPath := pn.GetPositionPoolKey(positionId)
220	pools := s.getPools()
221
222	pool, ok := pools.Get(poolPath)
223	if !ok {
224		panic(makeErrorWithDetails(
225			errNonIncentivizedPool,
226			ufmt.Sprintf("cannot stake position to non existing pool(%s)", poolPath),
227		))
228	}
229
230	err := s.poolHasIncentives(pool)
231	if err != nil {
232		panic(err.Error())
233	}
234
235	liquidity := getLiquidity(positionId)
236	tickLower, tickUpper := getTickOf(positionId)
237
238	warmups := s.store.GetWarmupTemplate()
239	currentWarmups := instantiateWarmup(warmups, currentTime)
240
241	// staked status
242	deposit := sr.NewDeposit(
243		caller,
244		poolPath,
245		liquidity,
246		currentTime,
247		tickLower,
248		tickUpper,
249		currentWarmups,
250	)
251
252	// when staking, add new created incentives to deposit
253	currentIncentiveIds := s.getExternalIncentiveIdsBy(poolPath, 0, currentTime)
254
255	for _, incentiveId := range currentIncentiveIds {
256		incentive := s.getExternalIncentives().get(incentiveId)
257		// If incentive is ended, not available to collect reward
258		if currentTime > incentive.EndTimestamp() {
259			continue
260		}
261
262		deposit.AddExternalIncentiveId(incentiveId)
263	}
264
265	// set last external incentive ids updated at
266	deposit.SetLastExternalIncentiveUpdatedAt(currentTime)
267
268	deposits := s.getDeposits()
269	deposits.set(positionId, deposit)
270
271	// transfer NFT ownership to staker contract
272	stakerAddr := access.MustGetAddress(prbac.ROLE_STAKER.String())
273	if err := s.transferDeposit(0, rlm, positionId, owner, caller, stakerAddr); err != nil {
274		panic(err.Error())
275	}
276
277	// after transfer, set caller(user) as position operator (to collect fee and reward)
278	pn.SetPositionOperator(cross(rlm), positionId, caller)
279
280	poolTier := s.getPoolTier()
281	poolTier.cacheReward(currentTime, pools)
282	s.updatePoolTier(0, rlm, poolTier)
283
284	signedLiquidity := i256.FromUint256(liquidity)
285	currentTick := s.poolAccessor.GetSlot0Tick(poolPath)
286
287	poolResolver := NewPoolResolver(pool)
288
289	isInRange := false
290	if pn.IsInRange(positionId) {
291		isInRange = true
292		poolResolver.modifyDeposit(signedLiquidity, currentTime, currentTick)
293	}
294	// historical tick must be set regardless of the deposit's range
295	if poolResolver.isChangedTick(currentTime, currentTick) {
296		poolResolver.Pool.SetHistoricalTickAt(currentTime, currentTick)
297	}
298
299	// This could happen because of how position stores the ticks.
300	// Ticks are negated if the token1 < token0.
301	poolResolver.TickResolver(tickUpper).modifyDepositUpper(currentTime, signedLiquidity)
302	poolResolver.TickResolver(tickLower).modifyDepositLower(currentTime, signedLiquidity)
303	s.getPools().set(poolPath, pool)
304
305	amount0, amount1 := s.calculateAmounts(poolPath, tickLower, tickUpper, liquidity)
306
307	// Get accumulator values for reward calculation tracking
308	_, globalAccX128 := poolResolver.CurrentGlobalRewardRatioAccumulation(currentTime)
309	stakedLiquidity := poolResolver.CurrentStakedLiquidity(currentTime)
310	lowerTickResolver := poolResolver.TickResolver(tickLower)
311	upperTickResolver := poolResolver.TickResolver(tickUpper)
312	lowerOutsideAccX128 := lowerTickResolver.CurrentOutsideAccumulation(currentTime)
313	upperOutsideAccX128 := upperTickResolver.CurrentOutsideAccumulation(currentTime)
314
315	chain.Emit(
316		"StakeToken",
317		"prevAddr", previousRealm.Address().String(),
318		"prevRealm", previousRealm.PkgPath(),
319		"positionId", utils.FormatUint(positionId),
320		"poolPath", poolPath,
321		"owner", owner.String(),
322		"liquidity", liquidity.ToString(),
323		"positionUpperTick", utils.FormatInt(tickUpper),
324		"positionLowerTick", utils.FormatInt(tickLower),
325		"currentTick", utils.FormatInt(currentTick),
326		"isInRange", utils.FormatBool(isInRange),
327		"referrer", actualReferrer,
328		"amount0", amount0.ToString(),
329		"amount1", amount1.ToString(),
330		"stakedLiquidity", stakedLiquidity.ToString(),
331		"globalRewardRatioAccX128", globalAccX128,
332		"lowerTickOutsideAccX128", lowerOutsideAccX128.ToString(),
333		"upperTickOutsideAccX128", upperOutsideAccX128.ToString(),
334	)
335
336	return poolPath
337}
338
339// transferDeposit transfers deposit ownership to a new address.
340//
341// Manages NFT custody during staking operations.
342// Transfers ownership to staker contract for reward eligibility.
343// Handles cases where the staker already holds custody.
344//
345// Parameters:
346//   - positionId: The ID of the position NFT to transfer
347//   - owner: The current owner of the position
348//   - caller: The entity initiating the transfer
349//   - to: The recipient address (usually staker contract)
350//
351// Security Features:
352//   - Prevents self-transfer exploits
353//   - Validates ownership before transfer
354//   - Atomic operation with staking
355//   - No transfer if owner == to (already in custody)
356//
357// Returns:
358//   - nil: If owner and recipient are same
359//   - error: If caller unauthorized or transfer fails
360//
361// NFT remains locked in staker until unstaking.
362// Otherwise delegates the transfer to `gnft.TransferFrom`.
363func (s *stakerV1) transferDeposit(_ int, rlm realm, positionId uint64, owner, caller, to address) error {
364	// If the recipient already owns the NFT, no transfer is needed.
365	if owner == to {
366		return nil
367	}
368
369	if caller == to {
370		return ufmt.Errorf(
371			"%v: only owner(%s) can transfer positionId(%d), called from %s",
372			errNoPermission, owner, positionId, caller,
373		)
374	}
375
376	// transfer NFT ownership
377	return s.nftAccessor.TransferFrom(0, rlm, owner, to, positionIdFrom(positionId))
378}
379
380// CollectReward harvests accumulated rewards for a staked position. This includes both
381// internal GNS emission and external incentive rewards.
382//
383// State Transition:
384//  1. Warm-up amounts are clears for both internal and external rewards
385//  2. Reward tokens are transferred to the owner
386//  3. Penalty fees are transferred to protocol/community addresses
387//  4. GNS balance is recalculated
388//
389// Requirements:
390//   - Contract must not be halted
391//   - Caller must be the position owner
392//   - Position must be staked (have a deposit record)
393//
394// Parameters:
395// CollectReward claims accumulated rewards without unstaking.
396//
397// Parameters:
398//   - positionId: LP position NFT token ID
399//
400// Returns poolPath, gnsAmount, externalRewards map, externalPenalties map.
401func (s *stakerV1) CollectReward(_ int, rlm realm, positionId uint64) (string, string, map[string]int64, map[string]int64) {
402	if !rlm.IsCurrent() {
403		panic(errors.New(errSpoofedRealm))
404	}
405
406	halt.AssertIsNotHaltedWithdraw()
407
408	caller := rlm.Previous().Address()
409	assertIsDepositor(s, caller, positionId)
410
411	deposit := s.getDeposits().get(positionId)
412	depositResolver := NewDepositResolver(deposit)
413
414	en.MintAndDistributeGns(cross(rlm))
415
416	currentTime := time.Now().Unix()
417	blockHeight := runtime.ChainHeight()
418	previousRealm := rlm.Previous()
419
420	// get all internal and external rewards
421	reward := s.calcPositionReward(blockHeight, currentTime, positionId)
422
423	// transfer external rewards to user
424	communityPoolAddr := access.MustGetAddress(prbac.ROLE_COMMUNITY_POOL.String())
425	toUserExternalReward := make(map[string]int64)
426	toUserExternalPenalty := make(map[string]int64)
427
428	for incentiveId, rewardAmount := range reward.External {
429		// Skip when user reward is zero.
430		// Do not update last collect time so the reward accrues until
431		// the next collection where a non-zero amount can be delivered.
432		if rewardAmount == 0 {
433			continue
434		}
435
436		incentive := s.getExternalIncentives().get(incentiveId)
437		if incentive == nil {
438			// Incentive could be missing; skip to keep collection working.
439			chain.Emit(
440				"SkippedMissingIncentive",
441				"prevAddr", previousRealm.Address().String(),
442				"prevRealm", previousRealm.PkgPath(),
443				"positionId", utils.FormatUint(positionId),
444				"incentiveId", incentiveId,
445				"currentTime", utils.FormatInt(currentTime),
446				"currentHeight", utils.FormatInt(blockHeight),
447			)
448			continue
449		}
450
451		incentiveResolver := NewExternalIncentiveResolver(incentive)
452		if !incentiveResolver.IsStarted(currentTime) {
453			continue
454		}
455
456		externalPenalty := reward.ExternalPenalty[incentiveId]
457		totalRewardAmount := gnsmath.SafeAddInt64(rewardAmount, externalPenalty)
458
459		if incentiveResolver.RewardAmount() < totalRewardAmount {
460			// Do not update last collect time here; insufficient funds should
461			// leave the incentive collectible when refilled or corrected.
462			chain.Emit(
463				"InsufficientExternalReward",
464				"prevAddr", previousRealm.Address().String(),
465				"prevRealm", previousRealm.PkgPath(),
466				"positionId", utils.FormatUint(positionId),
467				"incentiveId", incentiveId,
468				"requiredAmount", utils.FormatInt(totalRewardAmount),
469				"availableAmount", utils.FormatInt(incentiveResolver.RewardAmount()),
470				"currentTime", utils.FormatInt(currentTime),
471				"currentHeight", utils.FormatInt(blockHeight),
472			)
473			continue
474		}
475
476		// process reward states
477		rewardToken := incentive.RewardToken()
478
479		toUserExternalReward[rewardToken] = gnsmath.SafeAddInt64(toUserExternalReward[rewardToken], rewardAmount)
480		toUserExternalPenalty[rewardToken] = gnsmath.SafeAddInt64(toUserExternalPenalty[rewardToken], externalPenalty)
481
482		incentive.SetRewardAmount(gnsmath.SafeSubInt64(incentive.RewardAmount(), totalRewardAmount))
483		incentiveResolver.addDistributedRewardAmount(rewardAmount)
484		incentiveResolver.addAccumulatedPenaltyAmount(externalPenalty)
485		depositResolver.addCollectedExternalReward(incentiveId, totalRewardAmount)
486
487		// Update the last collect time ONLY for this specific incentive
488		// This happens only if the reward was successfully transferred.
489		err := depositResolver.updateExternalRewardLastCollectTime(incentiveId, currentTime)
490		if err != nil {
491			panic(err)
492		}
493
494		// If incentive ended and user already collected after end, remove from index
495		// This ensures deposit's incentive list shrinks over time as incentives complete
496		if depositResolver.ExternalRewardLastCollectTime(incentiveId) > incentiveResolver.EndTimestamp() {
497			deposit.RemoveExternalIncentiveId(incentiveId)
498		}
499
500		// update
501		s.getExternalIncentives().set(incentiveId, incentive)
502
503		toUser, feeAmount, err := s.handleStakingRewardFee(0, rlm, rewardToken, rewardAmount, false)
504		if err != nil {
505			panic(err.Error())
506		}
507
508		if toUser > 0 {
509			common.SafeGRC20Transfer(cross(rlm), rewardToken, deposit.Owner(), toUser)
510		}
511
512		chain.Emit(
513			"ProtocolFeeExternalReward",
514			"prevAddr", previousRealm.Address().String(),
515			"prevRealm", previousRealm.PkgPath(),
516			"fromPositionId", utils.FormatUint(positionId),
517			"fromPoolPath", incentive.TargetPoolPath(),
518			"feeTokenPath", rewardToken,
519			"feeAmount", utils.FormatInt(feeAmount),
520			"currentTime", utils.FormatInt(currentTime),
521			"currentHeight", utils.FormatInt(blockHeight),
522		)
523
524		pool, _ := s.getPools().Get(deposit.TargetPoolPath())
525		poolResolver := NewPoolResolver(pool)
526		_, globalAccX128 := poolResolver.CurrentGlobalRewardRatioAccumulation(currentTime)
527		stakedLiquidity := poolResolver.CurrentStakedLiquidity(currentTime)
528
529		tickLower := deposit.TickLower()
530		tickUpper := deposit.TickUpper()
531		lowerOutsideAccX128 := poolResolver.TickResolver(tickLower).CurrentOutsideAccumulation(currentTime)
532		upperOutsideAccX128 := poolResolver.TickResolver(tickUpper).CurrentOutsideAccumulation(currentTime)
533
534		chain.Emit(
535			"CollectReward",
536			"prevAddr", previousRealm.Address().String(),
537			"prevRealm", previousRealm.PkgPath(),
538			"positionId", utils.FormatUint(positionId),
539			"poolPath", deposit.TargetPoolPath(),
540			"recipient", deposit.Owner().String(),
541			"incentiveId", incentiveId,
542			"rewardToken", rewardToken,
543			"rewardAmount", utils.FormatInt(rewardAmount),
544			"rewardToUser", utils.FormatInt(toUser),
545			"rewardToFee", utils.FormatInt(rewardAmount-toUser),
546			"rewardPenalty", utils.FormatInt(externalPenalty),
547			"currentTime", utils.FormatInt(currentTime),
548			"currentHeight", utils.FormatInt(blockHeight),
549			"stakedLiquidity", stakedLiquidity.ToString(),
550			"globalRewardRatioAccX128", globalAccX128,
551			"lowerTickOutsideAccX128", lowerOutsideAccX128.ToString(),
552			"upperTickOutsideAccX128", upperOutsideAccX128.ToString(),
553		)
554	}
555
556	internalReward := int64(0)
557	internalRewardToUser := int64(0)
558	internalRewardToFee := int64(0)
559	internalRewardPenalty := int64(0)
560
561	// Skip internal reward state update when user reward is zero (only penalty).
562	// Do not update last collect time so the reward accrues until the next
563	// collection where a non-zero amount can be delivered.
564	skipInternalUpdate := reward.Internal == 0
565
566	// internal reward to user
567	if !skipInternalUpdate {
568		toUser, feeAmount, err := s.handleStakingRewardFee(0, rlm, GNS_TOKEN_KEY, reward.Internal, true)
569		if err != nil {
570			panic(err.Error())
571		}
572
573		internalReward = reward.Internal
574		internalRewardToUser = toUser
575		internalRewardToFee = feeAmount
576		internalRewardPenalty = reward.InternalPenalty
577
578		chain.Emit(
579			"ProtocolFeeInternalReward",
580			"prevAddr", previousRealm.Address().String(),
581			"prevRealm", previousRealm.PkgPath(),
582			"fromPositionId", utils.FormatUint(positionId),
583			"fromPoolPath", deposit.TargetPoolPath(),
584			"feeTokenPath", GNS_TOKEN_KEY,
585			"feeAmount", utils.FormatInt(internalRewardToFee),
586			"currentTime", utils.FormatInt(currentTime),
587			"currentHeight", utils.FormatInt(blockHeight),
588		)
589	}
590
591	totalEmissionSent := s.store.GetTotalEmissionSent()
592
593	if internalRewardToUser > 0 {
594		// internal reward to user
595		totalEmissionSent = gnsmath.SafeAddInt64(totalEmissionSent, internalRewardToUser)
596		depositResolver.addCollectedInternalReward(reward.Internal)
597	}
598
599	if internalRewardPenalty > 0 {
600		// internal penalty to community pool
601		totalEmissionSent = gnsmath.SafeAddInt64(totalEmissionSent, internalRewardPenalty)
602		depositResolver.addCollectedInternalReward(internalRewardPenalty)
603	}
604
605	// Unclaimable must be processed after regular rewards so that accumulated
606	// unclaimable amounts are reset in the same collect window.
607	unClaimableInternal := s.processUnClaimableReward(depositResolver.TargetPoolPath(), currentTime)
608	if unClaimableInternal > 0 {
609		totalEmissionSent = gnsmath.SafeAddInt64(totalEmissionSent, unClaimableInternal)
610	}
611
612	err := s.store.SetTotalEmissionSent(0, rlm, totalEmissionSent)
613	if err != nil {
614		panic(err)
615	}
616
617	if !skipInternalUpdate {
618		// Update lastCollectTime for internal rewards (GNS emissions)
619		err = depositResolver.updateInternalRewardLastCollectTime(currentTime)
620		if err != nil {
621			panic(err)
622		}
623	}
624
625	deposits := s.getDeposits()
626	deposits.set(positionId, deposit)
627
628	if internalRewardToUser > 0 {
629		gns.Transfer(cross(rlm), deposit.Owner(), internalRewardToUser)
630	}
631
632	if internalRewardPenalty > 0 {
633		gns.Transfer(cross(rlm), communityPoolAddr, internalRewardPenalty)
634	}
635
636	if unClaimableInternal > 0 {
637		gns.Transfer(cross(rlm), communityPoolAddr, unClaimableInternal)
638	}
639
640	rewardToUser := utils.FormatInt(internalRewardToUser)
641	rewardPenalty := utils.FormatInt(internalRewardPenalty)
642
643	if !skipInternalUpdate {
644		poolPath := depositResolver.TargetPoolPath()
645		pool, _ := s.getPools().Get(poolPath)
646		poolResolver := NewPoolResolver(pool)
647
648		// Get accumulator values for reward calculation tracking
649		_, globalAccX128 := poolResolver.CurrentGlobalRewardRatioAccumulation(currentTime)
650		stakedLiquidity := poolResolver.CurrentStakedLiquidity(currentTime)
651		lowerTickResolver := poolResolver.TickResolver(deposit.TickLower())
652		upperTickResolver := poolResolver.TickResolver(deposit.TickUpper())
653		lowerOutsideAccX128 := lowerTickResolver.CurrentOutsideAccumulation(currentTime)
654		upperOutsideAccX128 := upperTickResolver.CurrentOutsideAccumulation(currentTime)
655
656		chain.Emit(
657			"CollectReward",
658			"prevAddr", previousRealm.Address().String(),
659			"prevRealm", previousRealm.PkgPath(),
660			"positionId", utils.FormatUint(positionId),
661			"poolPath", depositResolver.TargetPoolPath(),
662			"recipient", depositResolver.Owner().String(),
663			"rewardToken", GNS_TOKEN_KEY,
664			"rewardAmount", utils.FormatInt(internalReward),
665			"rewardToUser", rewardToUser,
666			"rewardToFee", utils.FormatInt(internalRewardToFee),
667			"rewardPenalty", rewardPenalty,
668			"rewardUnClaimableAmount", utils.FormatInt(unClaimableInternal),
669			"currentTime", utils.FormatInt(currentTime),
670			"currentHeight", utils.FormatInt(blockHeight),
671			"stakedLiquidity", stakedLiquidity.ToString(),
672			"globalRewardRatioAccX128", globalAccX128,
673			"lowerTickOutsideAccX128", lowerOutsideAccX128.ToString(),
674			"upperTickOutsideAccX128", upperOutsideAccX128.ToString(),
675		)
676	}
677
678	return rewardToUser, rewardPenalty, toUserExternalReward, toUserExternalPenalty
679}
680
681// UnStakeToken withdraws an LP token from staking, collecting all pending rewards
682// and returning the token to its original owner.
683//
684// Parameters:
685//   - positionId: LP position NFT token ID to unstake
686//   - unwrapResult: Convert WUGNOT to GNOT if true
687//
688// Process:
689//  1. Collects all pending rewards (GNS + external)
690//  2. Transfers NFT ownership back to original owner
691//  3. Clears position operator rights
692//  4. Removes from reward tracking systems
693//  5. Cleans up all staking metadata
694//
695// Returns:
696//   - poolPath: Pool identifier where position was staked
697//
698// Requirements:
699//   - Caller must be the depositor
700//   - Position must be currently staked
701func (s *stakerV1) UnStakeToken(_ int, rlm realm, positionId uint64) string { // poolPath
702	if !rlm.IsCurrent() {
703		panic(errors.New(errSpoofedRealm))
704	}
705
706	caller := rlm.Previous().Address()
707	halt.AssertIsNotHaltedWithdraw()
708	assertIsDepositor(s, caller, positionId)
709
710	deposit := s.getDeposits().get(positionId)
711
712	// unStaked status
713	poolPath := deposit.TargetPoolPath()
714
715	// claim All Rewards
716	s.CollectReward(0, rlm, positionId)
717
718	if err := s.applyUnStake(positionId); err != nil {
719		panic(err)
720	}
721
722	// transfer NFT ownership to origin owner
723	stakerAddr := access.MustGetAddress(prbac.ROLE_STAKER.String())
724	s.nftAccessor.TransferFrom(0, rlm, stakerAddr, deposit.Owner(), positionIdFrom(positionId))
725	pn.SetPositionOperator(cross(rlm), positionId, ZERO_ADDRESS)
726
727	// get position information for event
728	liquidity := getLiquidity(positionId)
729	tickLower, tickUpper := getTickOf(positionId)
730
731	amount0, amount1 := s.calculateAmounts(poolPath, tickLower, tickUpper, liquidity)
732
733	// Get pool and accumulator values for reward calculation tracking
734	currentTime := time.Now().Unix()
735	pool, _ := s.getPools().Get(poolPath)
736	poolResolver := NewPoolResolver(pool)
737	currentTick := s.poolAccessor.GetSlot0Tick(poolPath)
738
739	_, globalAccX128 := poolResolver.CurrentGlobalRewardRatioAccumulation(currentTime)
740	stakedLiquidity := poolResolver.CurrentStakedLiquidity(currentTime)
741
742	previousRealm := rlm.Previous()
743	chain.Emit(
744		"UnStakeToken",
745		"prevAddr", previousRealm.Address().String(),
746		"prevRealm", previousRealm.PkgPath(),
747		"positionId", utils.FormatUint(positionId),
748		"poolPath", poolPath,
749		"owner", deposit.Owner().String(),
750		"liquidity", liquidity.ToString(),
751		"positionUpperTick", utils.FormatInt(tickUpper),
752		"positionLowerTick", utils.FormatInt(tickLower),
753		"amount0", amount0.ToString(),
754		"amount1", amount1.ToString(),
755		"from", stakerAddr.String(),
756		"to", deposit.Owner().String(),
757		"currentTick", utils.FormatInt(currentTick),
758		"stakedLiquidity", stakedLiquidity.ToString(),
759		"globalRewardRatioAccX128", globalAccX128,
760	)
761
762	return poolPath
763}
764
765func (s *stakerV1) applyUnStake(positionId uint64) error {
766	deposit := s.getDeposits().get(positionId)
767	depositResolver := NewDepositResolver(deposit)
768	pool, ok := s.getPools().Get(depositResolver.TargetPoolPath())
769	poolResolver := NewPoolResolver(pool)
770	if !ok {
771		return ufmt.Errorf(
772			"%v: pool(%s) does not exist",
773			errDataNotFound, depositResolver.TargetPoolPath(),
774		)
775	}
776
777	currentTime := time.Now().Unix()
778	currentTick := s.poolAccessor.GetSlot0Tick(depositResolver.TargetPoolPath())
779	signedLiquidity := i256.Zero().Neg(i256.FromUint256(depositResolver.Liquidity()))
780	if pn.IsInRange(positionId) {
781		poolResolver.modifyDeposit(signedLiquidity, currentTime, currentTick)
782	}
783
784	upperTick := poolResolver.TickResolver(depositResolver.TickUpper())
785	lowerTick := poolResolver.TickResolver(depositResolver.TickLower())
786	upperTick.modifyDepositUpper(currentTime, signedLiquidity)
787	lowerTick.modifyDepositLower(currentTime, signedLiquidity)
788
789	s.getDeposits().remove(positionId)
790
791	return nil
792}
793
794// poolHasIncentives checks if the pool has any stakeable incentives (internal or external).
795// External incentive eligibility (active or within short future window) is handled inside IsExternallyIncentivizedPool.
796func (s *stakerV1) poolHasIncentives(pool *sr.Pool) error {
797	poolPath := pool.PoolPath()
798	hasInternal := s.getPoolTier().IsInternallyIncentivizedPool(poolPath)
799	hasExternal := NewPoolResolver(pool).IsExternallyIncentivizedPool()
800
801	if !hasInternal && !hasExternal {
802		return ufmt.Errorf(
803			"%v: cannot stake position to non incentivized pool(%s)",
804			errNonIncentivizedPool, poolPath,
805		)
806	}
807
808	return nil
809}
810
811// tokenHasLiquidity checks if the target positionId has non-zero liquidity
812func tokenHasLiquidity(positionId uint64) error {
813	if getLiquidity(positionId).Lte(u256.Zero()) {
814		return ufmt.Errorf(
815			"%v: positionId(%d) has no liquidity",
816			errZeroLiquidity, positionId,
817		)
818	}
819	return nil
820}
821
822func getLiquidity(positionId uint64) *u256.Uint {
823	return u256.MustFromDecimal(pn.GetPositionLiquidity(positionId))
824}
825
826func getTickOf(positionId uint64) (int32, int32) {
827	tickLower := pn.GetPositionTickLower(positionId)
828	tickUpper := pn.GetPositionTickUpper(positionId)
829	if tickUpper < tickLower {
830		panic(ufmt.Sprintf("tickUpper(%d) is less than tickLower(%d)", tickUpper, tickLower))
831	}
832	return tickLower, tickUpper
833}
834
835// calculateAmounts calculates the amounts of token0 and token1 for a given liquidity and range.
836func (s *stakerV1) calculateAmounts(poolPath string, tickLower, tickUpper int32, liquidity *u256.Uint) (*u256.Uint, *u256.Uint) {
837	sqrtPriceX96 := u256.MustFromDecimal(s.poolAccessor.GetSlot0SqrtPriceX96(poolPath))
838	sqrtPriceLowerX96 := gnsmath.TickMathGetSqrtRatioAtTick(tickLower)
839	sqrtPriceUpperX96 := gnsmath.TickMathGetSqrtRatioAtTick(tickUpper)
840
841	return gnsmath.GetAmountsForLiquidity(sqrtPriceX96, sqrtPriceLowerX96, sqrtPriceUpperX96, liquidity)
842}