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