Search Apps Documentation Source Content File Folder Download Copy Actions Download

staker.gno

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