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

28.08 Kb · 858 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// stakeScanLowerBound returns the lower bound of the stake-time incentive scan
175// window, clamped to 0. The start-time index encodes keys as unsigned, so a
176// negative bound aborts when the chain time is under TIMESTAMP_365DAYS.
177func stakeScanLowerBound(currentTime int64) int64 {
178	if currentTime < TIMESTAMP_365DAYS {
179		return 0
180	}
181
182	return currentTime - TIMESTAMP_365DAYS
183}
184
185// StakeToken stakes an LP position NFT to earn rewards.
186//
187// Transfers position NFT to staker and begins reward accumulation.
188// Eligible for internal incentives (GNS emission) and external rewards.
189// Position must have liquidity and be in eligible pool tier.
190//
191// Parameters:
192//   - positionId: LP position NFT token ID to stake
193//   - referrer: Optional referral address for tracking
194//
195// Returns:
196//   - poolPath: Pool identifier (token0:token1:fee)
197//
198// Requirements:
199//   - Caller must own the position NFT
200//   - Position must have active liquidity
201//   - Pool must be in tier 1, 2, or 3
202//   - Position not already staked
203//
204// Note: Out-of-range positions earn no rewards but can be staked.
205func (s *stakerV1) StakeToken(_ int, rlm realm, positionId uint64, referrer string) string {
206	if !rlm.IsCurrent() {
207		panic(errors.New(errSpoofedRealm))
208	}
209
210	halt.AssertIsNotHaltedStaker()
211
212	assertIsNotStaked(s, positionId)
213
214	en.MintAndDistributeGns(cross(rlm))
215
216	previousRealm := rlm.Previous()
217	caller := previousRealm.Address()
218	currentTime := time.Now().Unix()
219
220	owner := s.nftAccessor.MustOwnerOf(positionIdFrom(positionId))
221	assertIsPositionOwner(owner, caller)
222
223	actualReferrer := referral.TryRegister(cross(rlm), caller, referrer)
224
225	if err := tokenHasLiquidity(positionId); err != nil {
226		panic(err.Error())
227	}
228
229	// check pool path from positionId
230	poolPath := pn.GetPositionPoolKey(positionId)
231	pools := s.getPools()
232
233	pool, ok := pools.Get(poolPath)
234	if !ok {
235		panic(makeErrorWithDetails(
236			errNonIncentivizedPool,
237			ufmt.Sprintf("cannot stake position to non existing pool(%s)", poolPath),
238		))
239	}
240
241	err := s.poolHasIncentives(pool)
242	if err != nil {
243		panic(err.Error())
244	}
245
246	liquidity := getLiquidity(positionId)
247	tickLower, tickUpper := getTickOf(positionId)
248
249	warmups := s.store.GetWarmupTemplate()
250	currentWarmups := instantiateWarmup(warmups, currentTime)
251
252	// staked status
253	deposit := sr.NewDeposit(
254		caller,
255		poolPath,
256		liquidity,
257		currentTime,
258		tickLower,
259		tickUpper,
260		currentWarmups,
261	)
262
263	// when staking, add new incentives to deposit.
264	//
265	// Incentive duration is capped at TIMESTAMP_365DAYS, so anything still
266	// active at currentTime starts within [currentTime-365d, currentTime].
267	// Incentives starting before that window have ended and are filtered by
268	// the EndTimestamp check below.
269	//
270	currentIncentiveIds := s.getExternalIncentiveIdsBy(poolPath, stakeScanLowerBound(currentTime), currentTime)
271
272	for _, incentiveId := range currentIncentiveIds {
273		incentive := s.getExternalIncentives().get(incentiveId)
274		// If incentive is ended, not available to collect reward
275		if currentTime > incentive.EndTimestamp() {
276			continue
277		}
278
279		deposit.AddExternalIncentiveId(incentiveId)
280	}
281
282	// set last external incentive ids updated at
283	deposit.SetLastExternalIncentiveUpdatedAt(currentTime)
284
285	deposits := s.getDeposits()
286	deposits.set(positionId, deposit)
287
288	// transfer NFT ownership to staker contract
289	stakerAddr := access.MustGetAddress(prbac.ROLE_STAKER.String())
290	if err := s.transferDeposit(0, rlm, positionId, owner, caller, stakerAddr); err != nil {
291		panic(err.Error())
292	}
293
294	// after transfer, set caller(user) as position operator (to collect fee and reward)
295	pn.SetPositionOperator(cross(rlm), positionId, caller)
296
297	poolTier := s.getPoolTier()
298	poolTier.cacheReward(currentTime, pools)
299	s.updatePoolTier(0, rlm, poolTier)
300
301	signedLiquidity := i256.FromUint256(liquidity)
302	currentTick := s.poolAccessor.GetSlot0Tick(poolPath)
303
304	poolResolver := NewPoolResolver(pool)
305
306	isInRange := false
307	if pn.IsInRange(positionId) {
308		isInRange = true
309		poolResolver.modifyDeposit(signedLiquidity, currentTime, currentTick)
310	}
311	// historical tick must be set regardless of the deposit's range
312	if poolResolver.isChangedTick(currentTime, currentTick) {
313		poolResolver.Pool.SetHistoricalTickAt(currentTime, currentTick)
314	}
315
316	// This could happen because of how position stores the ticks.
317	// Ticks are negated if the token1 < token0.
318	upperTick := poolResolver.GetOrNewTick(tickUpper)
319	NewTickResolver(upperTick).modifyDepositUpper(currentTime, signedLiquidity)
320	pool.Ticks().SetTick(tickUpper, upperTick)
321
322	lowerTick := poolResolver.GetOrNewTick(tickLower)
323	NewTickResolver(lowerTick).modifyDepositLower(currentTime, signedLiquidity)
324	pool.Ticks().SetTick(tickLower, lowerTick)
325	s.getPools().set(poolPath, pool)
326
327	amount0, amount1 := s.calculateAmounts(poolPath, tickLower, tickUpper, liquidity)
328
329	// Get accumulator values for reward calculation tracking
330	globalAccX128, stakedLiquidity := poolResolver.globalRewardRatioAccumulationAt(currentTime)
331	lowerTickResolver := NewTickResolver(poolResolver.GetOrNewTick(tickLower))
332	upperTickResolver := NewTickResolver(poolResolver.GetOrNewTick(tickUpper))
333	lowerOutsideAccX128 := lowerTickResolver.CurrentOutsideAccumulation(currentTime)
334	upperOutsideAccX128 := upperTickResolver.CurrentOutsideAccumulation(currentTime)
335
336	chain.Emit(
337		"StakeToken",
338		"prevAddr", previousRealm.Address().String(),
339		"prevRealm", previousRealm.PkgPath(),
340		"positionId", utils.FormatUint(positionId),
341		"poolPath", poolPath,
342		"owner", owner.String(),
343		"liquidity", liquidity.ToString(),
344		"positionUpperTick", utils.FormatInt(tickUpper),
345		"positionLowerTick", utils.FormatInt(tickLower),
346		"currentTick", utils.FormatInt(currentTick),
347		"isInRange", utils.FormatBool(isInRange),
348		"referrer", actualReferrer,
349		"amount0", amount0.ToString(),
350		"amount1", amount1.ToString(),
351		"stakedLiquidity", stakedLiquidity.ToString(),
352		"globalRewardRatioAccX128", globalAccX128.ToString(),
353		"lowerTickOutsideAccX128", lowerOutsideAccX128.ToString(),
354		"upperTickOutsideAccX128", upperOutsideAccX128.ToString(),
355	)
356
357	return poolPath
358}
359
360// transferDeposit transfers deposit ownership to a new address.
361//
362// Manages NFT custody during staking operations.
363// Transfers ownership to staker contract for reward eligibility.
364// Handles cases where the staker already holds custody.
365//
366// Parameters:
367//   - positionId: The ID of the position NFT to transfer
368//   - owner: The current owner of the position
369//   - caller: The entity initiating the transfer
370//   - to: The recipient address (usually staker contract)
371//
372// Security Features:
373//   - Prevents self-transfer exploits
374//   - Validates ownership before transfer
375//   - Atomic operation with staking
376//   - No transfer if owner == to (already in custody)
377//
378// Returns:
379//   - nil: If owner and recipient are same
380//   - error: If caller unauthorized or transfer fails
381//
382// NFT remains locked in staker until unstaking.
383// Otherwise delegates the transfer to `gnft.TransferFrom`.
384func (s *stakerV1) transferDeposit(_ int, rlm realm, positionId uint64, owner, caller, to address) error {
385	// If the recipient already owns the NFT, no transfer is needed.
386	if owner == to {
387		return nil
388	}
389
390	if caller == to {
391		return ufmt.Errorf(
392			"%v: only owner(%s) can transfer positionId(%d), called from %s",
393			errNoPermission, owner, positionId, caller,
394		)
395	}
396
397	// transfer NFT ownership
398	return s.nftAccessor.TransferFrom(0, rlm, owner, to, positionIdFrom(positionId))
399}
400
401// CollectReward harvests accumulated rewards for a staked position. This includes both
402// internal GNS emission and external incentive rewards.
403//
404// State Transition:
405//  1. Warm-up amounts are clears for both internal and external rewards
406//  2. Reward tokens are transferred to the owner
407//  3. Penalty fees are transferred to protocol/community addresses
408//  4. GNS balance is recalculated
409//
410// Requirements:
411//   - Contract must not be halted
412//   - Caller must be the position owner
413//   - Position must be staked (have a deposit record)
414//
415// Parameters:
416// CollectReward claims accumulated rewards without unstaking.
417//
418// Parameters:
419//   - positionId: LP position NFT token ID
420//
421// Returns poolPath, gnsAmount, externalRewards map, externalPenalties map.
422func (s *stakerV1) CollectReward(_ int, rlm realm, positionId uint64) (string, string, map[string]int64, map[string]int64) {
423	if !rlm.IsCurrent() {
424		panic(errors.New(errSpoofedRealm))
425	}
426
427	halt.AssertIsNotHaltedWithdraw()
428
429	caller := rlm.Previous().Address()
430	assertIsDepositor(s, caller, positionId)
431
432	deposit := s.getDeposits().get(positionId)
433	depositResolver := NewDepositResolver(deposit)
434
435	en.MintAndDistributeGns(cross(rlm))
436
437	currentTime := time.Now().Unix()
438	blockHeight := runtime.ChainHeight()
439	previousRealm := rlm.Previous()
440
441	// get all internal and external rewards.
442	// Calculation is read-only; the resulting state updates (reward-cache materialization, lazy pool
443	// persistence, deposit incentive-index updates) are applied via updatePositionReward, which is the
444	// collect-only counterpart to the calculation shared with the Collectable* view getters.
445	rewardParam := &calculatePositionRewardParam{
446		CurrentHeight: blockHeight,
447		CurrentTime:   currentTime,
448		Deposits:      s.getDeposits(),
449		Pools:         s.getPools(),
450		PoolTier:      s.getPoolTier(),
451		PositionId:    positionId,
452	}
453	rewards, rewardUpdate := s.calculatePositionReward(rewardParam)
454
455	// aggregate the reward of internal and external rewards
456	reward := aggregateRewards(rewards)
457
458	// update the position reward state
459	s.updatePositionReward(rewardParam, rewardUpdate)
460
461	// Snapshot the accumulator values used for reward calculation tracking.
462	pool, _ := s.getPools().Get(deposit.TargetPoolPath())
463	poolResolver := NewPoolResolver(pool)
464	globalAccX128, stakedLiquidity := poolResolver.globalRewardRatioAccumulationAt(currentTime)
465
466	lowerTickResolver := NewTickResolver(poolResolver.GetOrNewTick(deposit.TickLower()))
467	upperTickResolver := NewTickResolver(poolResolver.GetOrNewTick(deposit.TickUpper()))
468	lowerOutsideAccX128 := lowerTickResolver.CurrentOutsideAccumulation(currentTime)
469	upperOutsideAccX128 := upperTickResolver.CurrentOutsideAccumulation(currentTime)
470
471	// transfer external rewards to user
472	communityPoolAddr := access.MustGetAddress(prbac.ROLE_COMMUNITY_POOL.String())
473	toUserExternalReward := make(map[string]int64)
474	toUserExternalPenalty := make(map[string]int64)
475
476	for incentiveId, rewardAmount := range reward.External {
477		// Skip when user reward is zero.
478		// Do not update last collect time so the reward accrues until
479		// the next collection where a non-zero amount can be delivered.
480		if rewardAmount == 0 {
481			continue
482		}
483
484		// get panics on a missing id; incentives are never removed from the tree.
485		incentive := s.getExternalIncentives().get(incentiveId)
486
487		incentiveResolver := NewExternalIncentiveResolver(incentive)
488		if !incentiveResolver.IsStarted(currentTime) {
489			continue
490		}
491
492		externalPenalty := reward.ExternalPenalty[incentiveId]
493		totalRewardAmount := gnsmath.SafeAddInt64(rewardAmount, externalPenalty)
494
495		if incentiveResolver.RewardAmount() < totalRewardAmount {
496			// Do not update last collect time here; insufficient funds should
497			// leave the incentive collectible when refilled or corrected.
498			chain.Emit(
499				"InsufficientExternalReward",
500				"prevAddr", previousRealm.Address().String(),
501				"prevRealm", previousRealm.PkgPath(),
502				"positionId", utils.FormatUint(positionId),
503				"incentiveId", incentiveId,
504				"requiredAmount", utils.FormatInt(totalRewardAmount),
505				"availableAmount", utils.FormatInt(incentiveResolver.RewardAmount()),
506				"currentTime", utils.FormatInt(currentTime),
507				"currentHeight", utils.FormatInt(blockHeight),
508			)
509			continue
510		}
511
512		// process reward states
513		rewardToken := incentive.RewardToken()
514
515		toUserExternalReward[rewardToken] = gnsmath.SafeAddInt64(toUserExternalReward[rewardToken], rewardAmount)
516		toUserExternalPenalty[rewardToken] = gnsmath.SafeAddInt64(toUserExternalPenalty[rewardToken], externalPenalty)
517
518		incentive.SetRewardAmount(gnsmath.SafeSubInt64(incentive.RewardAmount(), totalRewardAmount))
519		incentiveResolver.addDistributedRewardAmount(rewardAmount)
520		incentiveResolver.addAccumulatedPenaltyAmount(externalPenalty)
521		depositResolver.addCollectedExternalReward(incentiveId, totalRewardAmount)
522
523		// Update the last collect time ONLY for this specific incentive
524		// This happens only if the reward was successfully transferred.
525		err := depositResolver.updateExternalRewardLastCollectTime(incentiveId, currentTime)
526		if err != nil {
527			panic(err)
528		}
529
530		// If incentive ended and user already collected after end, remove from index
531		// This ensures deposit's incentive list shrinks over time as incentives complete
532		if depositResolver.ExternalRewardLastCollectTime(incentiveId) > incentiveResolver.EndTimestamp() {
533			deposit.RemoveExternalIncentiveId(incentiveId)
534		}
535
536		// update
537		s.getExternalIncentives().set(incentiveId, incentive)
538
539		toUser, feeAmount, err := s.handleStakingRewardFee(0, rlm, rewardToken, rewardAmount, false)
540		if err != nil {
541			panic(err.Error())
542		}
543
544		if toUser > 0 {
545			common.SafeGRC20Transfer(cross(rlm), rewardToken, deposit.Owner(), toUser)
546		}
547
548		chain.Emit(
549			"ProtocolFeeExternalReward",
550			"prevAddr", previousRealm.Address().String(),
551			"prevRealm", previousRealm.PkgPath(),
552			"fromPositionId", utils.FormatUint(positionId),
553			"fromPoolPath", incentive.TargetPoolPath(),
554			"feeTokenPath", rewardToken,
555			"feeAmount", utils.FormatInt(feeAmount),
556			"currentTime", utils.FormatInt(currentTime),
557			"currentHeight", utils.FormatInt(blockHeight),
558		)
559
560		chain.Emit(
561			"CollectReward",
562			"prevAddr", previousRealm.Address().String(),
563			"prevRealm", previousRealm.PkgPath(),
564			"positionId", utils.FormatUint(positionId),
565			"poolPath", deposit.TargetPoolPath(),
566			"recipient", deposit.Owner().String(),
567			"incentiveId", incentiveId,
568			"rewardToken", rewardToken,
569			"rewardAmount", utils.FormatInt(rewardAmount),
570			"rewardToUser", utils.FormatInt(toUser),
571			"rewardToFee", utils.FormatInt(rewardAmount-toUser),
572			"rewardPenalty", utils.FormatInt(externalPenalty),
573			"currentTime", utils.FormatInt(currentTime),
574			"currentHeight", utils.FormatInt(blockHeight),
575			"stakedLiquidity", stakedLiquidity.ToString(),
576			"globalRewardRatioAccX128", globalAccX128.ToString(),
577			"lowerTickOutsideAccX128", lowerOutsideAccX128.ToString(),
578			"upperTickOutsideAccX128", upperOutsideAccX128.ToString(),
579		)
580	}
581
582	internalReward := int64(0)
583	internalRewardToUser := int64(0)
584	internalRewardToFee := int64(0)
585	internalRewardPenalty := int64(0)
586
587	// Skip internal reward state update when user reward is zero (only penalty).
588	// Do not update last collect time so the reward accrues until the next
589	// collection where a non-zero amount can be delivered.
590	skipInternalUpdate := reward.Internal == 0
591
592	// internal reward to user
593	if !skipInternalUpdate {
594		toUser, feeAmount, err := s.handleStakingRewardFee(0, rlm, GNS_TOKEN_KEY, reward.Internal, true)
595		if err != nil {
596			panic(err.Error())
597		}
598
599		internalReward = reward.Internal
600		internalRewardToUser = toUser
601		internalRewardToFee = feeAmount
602		internalRewardPenalty = reward.InternalPenalty
603
604		chain.Emit(
605			"ProtocolFeeInternalReward",
606			"prevAddr", previousRealm.Address().String(),
607			"prevRealm", previousRealm.PkgPath(),
608			"fromPositionId", utils.FormatUint(positionId),
609			"fromPoolPath", deposit.TargetPoolPath(),
610			"feeTokenPath", GNS_TOKEN_KEY,
611			"feeAmount", utils.FormatInt(internalRewardToFee),
612			"currentTime", utils.FormatInt(currentTime),
613			"currentHeight", utils.FormatInt(blockHeight),
614		)
615	}
616
617	totalEmissionSent := s.store.GetTotalEmissionSent()
618
619	if internalRewardToUser > 0 {
620		// internal reward to user
621		totalEmissionSent = gnsmath.SafeAddInt64(totalEmissionSent, internalRewardToUser)
622		depositResolver.addCollectedInternalReward(reward.Internal)
623	}
624
625	if internalRewardPenalty > 0 {
626		// internal penalty to community pool
627		totalEmissionSent = gnsmath.SafeAddInt64(totalEmissionSent, internalRewardPenalty)
628		depositResolver.addCollectedInternalReward(internalRewardPenalty)
629	}
630
631	// Unclaimable must be processed after regular rewards so that accumulated
632	// unclaimable amounts are reset in the same collect window.
633	unClaimableInternal := s.processUnClaimableReward(depositResolver.TargetPoolPath(), currentTime)
634	if unClaimableInternal > 0 {
635		totalEmissionSent = gnsmath.SafeAddInt64(totalEmissionSent, unClaimableInternal)
636	}
637
638	err := s.store.SetTotalEmissionSent(0, rlm, totalEmissionSent)
639	if err != nil {
640		panic(err)
641	}
642
643	if !skipInternalUpdate {
644		// Update lastCollectTime for internal rewards (GNS emissions)
645		err = depositResolver.updateInternalRewardLastCollectTime(currentTime)
646		if err != nil {
647			panic(err)
648		}
649	}
650
651	deposits := s.getDeposits()
652	deposits.set(positionId, deposit)
653
654	if internalRewardToUser > 0 {
655		gns.Transfer(cross(rlm), deposit.Owner(), internalRewardToUser)
656	}
657
658	if internalRewardPenalty > 0 {
659		gns.Transfer(cross(rlm), communityPoolAddr, internalRewardPenalty)
660	}
661
662	if unClaimableInternal > 0 {
663		gns.Transfer(cross(rlm), communityPoolAddr, unClaimableInternal)
664	}
665
666	rewardToUser := utils.FormatInt(internalRewardToUser)
667	rewardPenalty := utils.FormatInt(internalRewardPenalty)
668
669	if !skipInternalUpdate {
670		chain.Emit(
671			"CollectReward",
672			"prevAddr", previousRealm.Address().String(),
673			"prevRealm", previousRealm.PkgPath(),
674			"positionId", utils.FormatUint(positionId),
675			"poolPath", depositResolver.TargetPoolPath(),
676			"recipient", depositResolver.Owner().String(),
677			"rewardToken", GNS_TOKEN_KEY,
678			"rewardAmount", utils.FormatInt(internalReward),
679			"rewardToUser", rewardToUser,
680			"rewardToFee", utils.FormatInt(internalRewardToFee),
681			"rewardPenalty", rewardPenalty,
682			"rewardUnClaimableAmount", utils.FormatInt(unClaimableInternal),
683			"currentTime", utils.FormatInt(currentTime),
684			"currentHeight", utils.FormatInt(blockHeight),
685			"stakedLiquidity", stakedLiquidity.ToString(),
686			"globalRewardRatioAccX128", globalAccX128.ToString(),
687			"lowerTickOutsideAccX128", lowerOutsideAccX128.ToString(),
688			"upperTickOutsideAccX128", upperOutsideAccX128.ToString(),
689		)
690	}
691
692	return rewardToUser, rewardPenalty, toUserExternalReward, toUserExternalPenalty
693}
694
695// UnStakeToken withdraws an LP token from staking, collecting all pending rewards
696// and returning the token to its original owner.
697//
698// Parameters:
699//   - positionId: LP position NFT token ID to unstake
700//   - unwrapResult: Convert WUGNOT to GNOT if true
701//
702// Process:
703//  1. Collects all pending rewards (GNS + external)
704//  2. Transfers NFT ownership back to original owner
705//  3. Clears position operator rights
706//  4. Removes from reward tracking systems
707//  5. Cleans up all staking metadata
708//
709// Returns:
710//   - poolPath: Pool identifier where position was staked
711//
712// Requirements:
713//   - Caller must be the depositor
714//   - Position must be currently staked
715func (s *stakerV1) UnStakeToken(_ int, rlm realm, positionId uint64) string { // poolPath
716	if !rlm.IsCurrent() {
717		panic(errors.New(errSpoofedRealm))
718	}
719
720	caller := rlm.Previous().Address()
721	halt.AssertIsNotHaltedWithdraw()
722	assertIsDepositor(s, caller, positionId)
723
724	deposit := s.getDeposits().get(positionId)
725
726	// unStaked status
727	poolPath := deposit.TargetPoolPath()
728
729	// claim All Rewards
730	s.CollectReward(0, rlm, positionId)
731
732	if err := s.applyUnStake(positionId); err != nil {
733		panic(err)
734	}
735
736	// transfer NFT ownership to origin owner
737	stakerAddr := access.MustGetAddress(prbac.ROLE_STAKER.String())
738	s.nftAccessor.TransferFrom(0, rlm, stakerAddr, deposit.Owner(), positionIdFrom(positionId))
739	pn.SetPositionOperator(cross(rlm), positionId, ZERO_ADDRESS)
740
741	// get position information for event
742	liquidity := getLiquidity(positionId)
743	tickLower, tickUpper := getTickOf(positionId)
744
745	amount0, amount1 := s.calculateAmounts(poolPath, tickLower, tickUpper, liquidity)
746
747	// Get pool and accumulator values for reward calculation tracking
748	currentTime := time.Now().Unix()
749	pool, _ := s.getPools().Get(poolPath)
750	poolResolver := NewPoolResolver(pool)
751	currentTick := s.poolAccessor.GetSlot0Tick(poolPath)
752
753	globalAccX128, stakedLiquidity := poolResolver.globalRewardRatioAccumulationAt(currentTime)
754
755	previousRealm := rlm.Previous()
756	chain.Emit(
757		"UnStakeToken",
758		"prevAddr", previousRealm.Address().String(),
759		"prevRealm", previousRealm.PkgPath(),
760		"positionId", utils.FormatUint(positionId),
761		"poolPath", poolPath,
762		"owner", deposit.Owner().String(),
763		"liquidity", liquidity.ToString(),
764		"positionUpperTick", utils.FormatInt(tickUpper),
765		"positionLowerTick", utils.FormatInt(tickLower),
766		"amount0", amount0.ToString(),
767		"amount1", amount1.ToString(),
768		"from", stakerAddr.String(),
769		"to", deposit.Owner().String(),
770		"currentTick", utils.FormatInt(currentTick),
771		"stakedLiquidity", stakedLiquidity.ToString(),
772		"globalRewardRatioAccX128", globalAccX128.ToString(),
773	)
774
775	return poolPath
776}
777
778func (s *stakerV1) applyUnStake(positionId uint64) error {
779	deposit := s.getDeposits().get(positionId)
780	depositResolver := NewDepositResolver(deposit)
781	pool, ok := s.getPools().Get(depositResolver.TargetPoolPath())
782	poolResolver := NewPoolResolver(pool)
783	if !ok {
784		return ufmt.Errorf(
785			"%v: pool(%s) does not exist",
786			errDataNotFound, depositResolver.TargetPoolPath(),
787		)
788	}
789
790	currentTime := time.Now().Unix()
791	currentTick := s.poolAccessor.GetSlot0Tick(depositResolver.TargetPoolPath())
792	signedLiquidity := i256.Zero().Neg(i256.FromUint256(depositResolver.Liquidity()))
793	if pn.IsInRange(positionId) {
794		poolResolver.modifyDeposit(signedLiquidity, currentTime, currentTick)
795	}
796
797	upperTick := poolResolver.GetOrNewTick(depositResolver.TickUpper())
798	NewTickResolver(upperTick).modifyDepositUpper(currentTime, signedLiquidity)
799	pool.Ticks().SetTick(depositResolver.TickUpper(), upperTick)
800
801	lowerTick := poolResolver.GetOrNewTick(depositResolver.TickLower())
802	NewTickResolver(lowerTick).modifyDepositLower(currentTime, signedLiquidity)
803	pool.Ticks().SetTick(depositResolver.TickLower(), lowerTick)
804
805	s.getDeposits().remove(positionId)
806
807	return nil
808}
809
810// poolHasIncentives checks if the pool has any stakeable incentives (internal or external).
811// External incentive eligibility (active or within short future window) is handled inside IsExternallyIncentivizedPool.
812func (s *stakerV1) poolHasIncentives(pool *sr.Pool) error {
813	poolPath := pool.PoolPath()
814	hasInternal := s.getPoolTier().IsInternallyIncentivizedPool(poolPath)
815	hasExternal := NewPoolResolver(pool).IsExternallyIncentivizedPool()
816
817	if !hasInternal && !hasExternal {
818		return ufmt.Errorf(
819			"%v: cannot stake position to non incentivized pool(%s)",
820			errNonIncentivizedPool, poolPath,
821		)
822	}
823
824	return nil
825}
826
827// tokenHasLiquidity checks if the target positionId has non-zero liquidity
828func tokenHasLiquidity(positionId uint64) error {
829	if getLiquidity(positionId).Lte(u256.Zero()) {
830		return ufmt.Errorf(
831			"%v: positionId(%d) has no liquidity",
832			errZeroLiquidity, positionId,
833		)
834	}
835	return nil
836}
837
838func getLiquidity(positionId uint64) *u256.Uint {
839	return u256.MustFromDecimal(pn.GetPositionLiquidity(positionId))
840}
841
842func getTickOf(positionId uint64) (int32, int32) {
843	tickLower := pn.GetPositionTickLower(positionId)
844	tickUpper := pn.GetPositionTickUpper(positionId)
845	if tickUpper < tickLower {
846		panic(ufmt.Sprintf("tickUpper(%d) is less than tickLower(%d)", tickUpper, tickLower))
847	}
848	return tickLower, tickUpper
849}
850
851// calculateAmounts calculates the amounts of token0 and token1 for a given liquidity and range.
852func (s *stakerV1) calculateAmounts(poolPath string, tickLower, tickUpper int32, liquidity *u256.Uint) (*u256.Uint, *u256.Uint) {
853	sqrtPriceX96 := u256.MustFromDecimal(s.poolAccessor.GetSlot0SqrtPriceX96(poolPath))
854	sqrtPriceLowerX96 := gnsmath.TickMathGetSqrtRatioAtTick(tickLower)
855	sqrtPriceUpperX96 := gnsmath.TickMathGetSqrtRatioAtTick(tickUpper)
856
857	return gnsmath.GetAmountsForLiquidity(sqrtPriceX96, sqrtPriceLowerX96, sqrtPriceUpperX96, liquidity)
858}