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}