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}