Search Apps Documentation Source Content File Folder Download Copy Actions Download

staker.gno

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