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}