staker.gno
53.95 Kb · 1453 lines
1package staker
2
3import (
4 "chain"
5 "chain/runtime"
6 "strings"
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/v1"
13 prbac "gno.land/p/gnoswap/rbac/v1"
14 "gno.land/p/gnoswap/utils/v1"
15
16 "gno.land/r/gnoswap/access/v1"
17 _ "gno.land/r/gnoswap/rbac/v1"
18
19 "gno.land/r/gnoswap/common"
20 "gno.land/r/gnoswap/halt/v1"
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/v1"
29 u256 "gno.land/p/gnoswap/uint256/v1"
30
31 "gno.land/r/gnoswap/referral/v1"
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.
42//
43// Returns:
44// - deposits: Empty deposit collection backed by a position-ID tree.
45func NewDeposits() *Deposits {
46 return &Deposits{
47 tree: sr.NewBPTreeN(16), // positionId -> *Deposit
48 }
49}
50
51// Has checks if a position ID exists in deposits.
52//
53// Parameters:
54// - positionId: LP position NFT ID whose deposit presence should be checked.
55//
56// Returns:
57// - exists: True when positionId is stored in the deposit tree; false otherwise.
58func (self *Deposits) Has(positionId uint64) bool {
59 return self.tree.Has(utils.EncodeUint64(positionId))
60}
61
62// Iterate traverses deposits within the specified range.
63//
64// Parameters:
65// - start: Lower position-ID bound passed to the tree iterator.
66// - end: Upper position-ID bound passed to the tree iterator.
67// - fn: Callback receiving each decoded position ID and deposit; return true to stop iteration or false to continue.
68func (self *Deposits) Iterate(start uint64, end uint64, fn func(positionId uint64, deposit *sr.Deposit) bool) {
69 self.tree.Iterate(utils.EncodeUint64(start), utils.EncodeUint64(end), func(positionId string, depositI any) bool {
70 dpst := retrieveDeposit(depositI)
71 return fn(utils.DecodeUint64(positionId), dpst)
72 })
73}
74
75// IterateByPoolPath traverses deposits in the ID range and invokes fn only for the requested pool.
76//
77// Parameters:
78// - start: Lower position-ID bound passed to the tree iterator.
79// - end: Upper position-ID bound passed to the tree iterator.
80// - poolPath: Pool identifier deposits must match before the callback is invoked.
81// - fn: Callback receiving each matching position ID and deposit; return true to stop iteration or false to continue.
82func (self *Deposits) IterateByPoolPath(start, end uint64, poolPath string, fn func(positionId uint64, deposit *sr.Deposit) bool) {
83 self.tree.Iterate(utils.EncodeUint64(start), utils.EncodeUint64(end), func(positionId string, depositI any) bool {
84 deposit := retrieveDeposit(depositI)
85 if deposit.TargetPoolPath() != poolPath {
86 return false
87 }
88
89 return fn(utils.DecodeUint64(positionId), deposit)
90 })
91}
92
93// Size returns the number of deposits.
94//
95// Returns:
96// - size: Number of deposits currently stored.
97func (self *Deposits) Size() int {
98 return self.tree.Size()
99}
100
101// get retrieves a deposit by position ID.
102func (self *Deposits) get(positionId uint64) *sr.Deposit {
103 depositI := self.tree.Get(utils.EncodeUint64(positionId))
104 if depositI == nil {
105 panic(makeErrorWithDetails(
106 errDataNotFound,
107 ufmt.Sprintf("positionId(%d) not found", positionId),
108 ))
109 }
110 return retrieveDeposit(depositI)
111}
112
113// retrieveDeposit safely casts data to Deposit type.
114func retrieveDeposit(data any) *sr.Deposit {
115 deposit, ok := data.(*sr.Deposit)
116 if !ok {
117 panic("failed to cast value to *Deposit")
118 }
119 return deposit
120}
121
122// set stores a deposit for a position ID.
123func (self *Deposits) set(positionId uint64, deposit *sr.Deposit) {
124 self.tree.Set(utils.EncodeUint64(positionId), deposit)
125}
126
127// remove deletes a deposit by position ID.
128func (self *Deposits) remove(positionId uint64) {
129 self.tree.Remove(utils.EncodeUint64(positionId))
130}
131
132// ExternalIncentives manages external incentive programs.
133type ExternalIncentives struct {
134 tree *bptree.BPTree
135}
136
137// NewExternalIncentives creates a new ExternalIncentives instance.
138//
139// Returns:
140// - incentives: Empty external-incentive collection backed by an incentive-ID tree.
141func NewExternalIncentives() *ExternalIncentives {
142 return &ExternalIncentives{
143 tree: sr.NewBPTreeN(16),
144 }
145}
146
147// Has checks if an incentive ID exists.
148//
149// Parameters:
150// - incentiveId: External incentive ID whose presence should be checked.
151//
152// Returns:
153// - exists: True when incentiveId is stored in the incentive tree; false otherwise.
154func (self *ExternalIncentives) Has(incentiveId string) bool { return self.tree.Has(incentiveId) }
155
156// Size returns the number of external incentives.
157//
158// Returns:
159// - size: Number of external incentives currently stored.
160func (self *ExternalIncentives) Size() int { return self.tree.Size() }
161
162// get retrieves an external incentive by ID.
163func (self *ExternalIncentives) get(incentiveId string) *sr.ExternalIncentive {
164 incentiveI := self.tree.Get(incentiveId)
165 if incentiveI == nil {
166 panic(makeErrorWithDetails(
167 errDataNotFound,
168 ufmt.Sprintf("incentiveId(%s) not found", incentiveId),
169 ))
170 }
171
172 incentive, ok := incentiveI.(*sr.ExternalIncentive)
173 if !ok {
174 panic("failed to cast value to *ExternalIncentive")
175 }
176 return incentive
177}
178
179// set stores an external incentive.
180func (self *ExternalIncentives) set(incentiveId string, incentive *sr.ExternalIncentive) {
181 self.tree.Set(incentiveId, incentive)
182}
183
184// remove deletes an external incentive by ID.
185func (self *ExternalIncentives) remove(incentiveId string) {
186 self.tree.Remove(incentiveId)
187}
188
189// EmissionCacheUpdateHook updates the emission cache when called.
190// This follows the same pattern as other hooks in the staker contract.
191func (s *stakerV1) emissionCacheUpdateHook(_ int, rlm realm, emissionAmountPerSecond int64) {
192 poolTier := s.getPoolTier()
193 if poolTier != nil {
194 currentTime := time.Now().Unix()
195 pools := s.getPools()
196
197 // First cache the current rewards before updating emission
198 poolTier.cacheReward(currentTime, pools)
199
200 // Update the current emission cache with the latest value
201 poolTier.currentEmission = emissionAmountPerSecond
202
203 // Now apply the new emission rate to each pool individually
204 poolTier.applyCacheToAllPools(pools, currentTime, emissionAmountPerSecond)
205
206 s.updatePoolTier(0, rlm, poolTier)
207 }
208}
209
210// stakeScanLowerBound returns the lower bound of the stake-time incentive scan
211// window, clamped to 0. The start-time index encodes keys as unsigned, so a
212// negative bound aborts when the chain time is under TIMESTAMP_365DAYS.
213func stakeScanLowerBound(currentTime int64) int64 {
214 if currentTime < TIMESTAMP_365DAYS {
215 return 0
216 }
217
218 return currentTime - TIMESTAMP_365DAYS
219}
220
221// StakeToken stakes an LP position NFT to earn rewards.
222//
223// Transfers position NFT to staker and begins reward accumulation.
224// Eligible for internal incentives (GNS emission) and external rewards.
225// A pool is stakeable when it has an internal tier or an active/future external incentive.
226//
227// Parameters:
228// - _: Noncrossing implementation-call discriminator; pass 0.
229// - rlm: Current realm context forwarded unchanged by the staker proxy.
230// - positionId: LP position NFT token ID to stake.
231// - referrer: Optional referral address for tracking.
232//
233// Returns:
234// - poolPath: Pool identifier (token0:token1:fee).
235//
236// Requirements:
237// - Caller must own the position NFT.
238// - Position must have non-zero liquidity.
239// - Pool must have an internal tier or an active/future external incentive.
240// - Position not already staked.
241//
242// Note: Out-of-range positions earn no rewards but can be staked.
243func (s *stakerV1) StakeToken(_ int, rlm realm, positionId uint64, referrer string) string {
244 access.AssertIsRlmCurrent(0, rlm)
245
246 halt.AssertIsNotHaltedStaker()
247
248 assertIsNotStaked(s, positionId)
249 assertHasNoExitCheckpoint(s, positionId)
250
251 en.MintAndDistributeGns(cross(rlm))
252
253 previousRealm := rlm.Previous()
254 caller := previousRealm.Address()
255 currentTime := time.Now().Unix()
256
257 owner, err := s.nftAccessor.OwnerOf(positionIdFrom(positionId))
258 if err != nil {
259 panic(err.Error())
260 }
261 assertIsPositionOwner(owner, caller)
262
263 actualReferrer := referral.TryRegister(cross(rlm), caller, referrer)
264
265 if err := tokenHasLiquidity(positionId); err != nil {
266 panic(err.Error())
267 }
268
269 // check pool path from positionId
270 poolPath, err := pn.GetPositionPoolKey(positionId)
271 if err != nil {
272 panic(err)
273 }
274 pools := s.getPools()
275
276 pool, ok := pools.Get(poolPath)
277 if !ok {
278 panic(makeErrorWithDetails(
279 errNonIncentivizedPool,
280 ufmt.Sprintf("cannot stake position to non existing pool(%s)", poolPath),
281 ))
282 }
283
284 err = s.poolHasIncentives(pool)
285 if err != nil {
286 panic(err.Error())
287 }
288
289 liquidity := getLiquidity(positionId)
290 tickLower, tickUpper := getTickOf(positionId)
291
292 warmups := s.store.GetWarmupTemplate()
293 currentWarmups := instantiateWarmup(warmups, currentTime)
294
295 // staked status
296 deposit := sr.NewDeposit(
297 caller,
298 poolPath,
299 liquidity,
300 currentTime,
301 tickLower,
302 tickUpper,
303 currentWarmups,
304 )
305
306 // when staking, add new incentives to deposit.
307 //
308 // Incentive duration is capped at TIMESTAMP_365DAYS, so anything still
309 // active at currentTime starts within [currentTime-365d, currentTime].
310 // Incentives starting before that window have ended and are filtered by
311 // the EndTimestamp check below.
312 //
313 currentIncentiveIds := s.getExternalIncentiveIdsBy(poolPath, stakeScanLowerBound(currentTime), 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 // transfer NFT ownership to staker contract
332 stakerAddr := access.MustGetAddress(prbac.ROLE_STAKER.String())
333 if err := s.transferDeposit(0, rlm, positionId, owner, caller, stakerAddr); err != nil {
334 panic(err.Error())
335 }
336
337 // after transfer, set caller(user) as position operator (to collect fee and reward)
338 pn.SetPositionOperator(cross(rlm), positionId, caller)
339
340 poolTier := s.getPoolTier()
341 poolTier.cacheRewardForPool(currentTime, pools, poolPath)
342
343 signedLiquidity := i256.FromUint256(liquidity)
344 currentTick := s.poolAccessor.GetSlot0Tick(poolPath)
345
346 poolResolver := NewPoolResolver(pool)
347
348 isInRange := false
349 inRange, err := pn.IsInRange(positionId)
350 if err != nil {
351 panic(err)
352 }
353 if inRange {
354 isInRange = true
355 poolResolver.modifyDeposit(signedLiquidity, currentTime, currentTick)
356 }
357 // historical tick must be set regardless of the deposit's range
358 if poolResolver.isChangedTick(currentTime, currentTick) {
359 poolResolver.Pool.SetHistoricalTickAt(currentTime, currentTick)
360 }
361
362 // This could happen because of how position stores the ticks.
363 // Ticks are negated if the token1 < token0.
364 // A tick this stake creates must be seeded before it is persisted.
365 upperTick := pool.Ticks().Get(tickUpper)
366 if upperTick == nil {
367 upperTick = sr.NewTick(tickUpper)
368 poolResolver.initializeNewTickOutsideAccumulation(currentTime, currentTick, tickUpper, upperTick)
369 }
370
371 lowerTick := pool.Ticks().Get(tickLower)
372 if lowerTick == nil {
373 lowerTick = sr.NewTick(tickLower)
374 poolResolver.initializeNewTickOutsideAccumulation(currentTime, currentTick, tickLower, lowerTick)
375 }
376
377 upperTickResolver := NewTickResolver(upperTick)
378 lowerTickResolver := NewTickResolver(lowerTick)
379
380 upperTickResolver.modifyDepositUpper(currentTime, signedLiquidity)
381 lowerTickResolver.modifyDepositLower(currentTime, signedLiquidity)
382
383 amount0, amount1 := s.calculateAmounts(poolPath, tickLower, tickUpper, liquidity)
384
385 // Get accumulator values for reward calculation tracking
386 globalAccX128, stakedLiquidity := poolResolver.globalRewardRatioAccumulationAt(currentTime)
387 lowerOutsideAccX128 := lowerTickResolver.CurrentOutsideAccumulation(currentTime)
388 upperOutsideAccX128 := upperTickResolver.CurrentOutsideAccumulation(currentTime)
389
390 pool.Ticks().SetTick(tickUpper, upperTick)
391 pool.Ticks().SetTick(tickLower, lowerTick)
392 s.getPools().set(poolPath, pool)
393
394 chain.Emit(
395 "StakeToken",
396 "prevAddr", previousRealm.Address().String(),
397 "prevRealm", previousRealm.PkgPath(),
398 "positionId", utils.FormatUint(positionId),
399 "poolPath", poolPath,
400 "owner", owner.String(),
401 "liquidity", liquidity.ToString(),
402 "positionUpperTick", utils.FormatInt(tickUpper),
403 "positionLowerTick", utils.FormatInt(tickLower),
404 "currentTick", utils.FormatInt(currentTick),
405 "isInRange", utils.FormatBool(isInRange),
406 "referrer", actualReferrer,
407 "amount0", amount0.ToString(),
408 "amount1", amount1.ToString(),
409 "stakedLiquidity", stakedLiquidity.ToString(),
410 "globalRewardRatioAccX128", globalAccX128.ToString(),
411 "lowerTickOutsideAccX128", lowerOutsideAccX128.ToString(),
412 "upperTickOutsideAccX128", upperOutsideAccX128.ToString(),
413 )
414
415 return poolPath
416}
417
418// transferDeposit transfers deposit ownership to a new address.
419//
420// Manages NFT custody during staking operations.
421// Transfers ownership to staker contract for reward eligibility.
422// Handles cases where the staker already holds custody.
423//
424// Parameters:
425// - positionId: The ID of the position NFT to transfer
426// - owner: The current owner of the position
427// - caller: The entity initiating the transfer
428// - to: The recipient address (usually staker contract)
429//
430// Security Features:
431// - Prevents self-transfer exploits
432// - Validates ownership before transfer
433// - Atomic operation with staking
434// - No transfer if owner == to (already in custody)
435//
436// Returns:
437// - nil: If owner and recipient are same
438// - error: If caller unauthorized or transfer fails
439//
440// NFT remains locked in staker until unstaking.
441// Otherwise delegates the transfer to `gnft.TransferFrom`.
442func (s *stakerV1) transferDeposit(_ int, rlm realm, positionId uint64, owner, caller, to address) error {
443 // If the recipient already owns the NFT, no transfer is needed.
444 if owner == to {
445 return nil
446 }
447
448 if caller == to {
449 return ufmt.Errorf(
450 "%v: only owner(%s) can transfer positionId(%d), called from %s",
451 errNoPermission, owner, positionId, caller,
452 )
453 }
454
455 // transfer NFT ownership
456 return s.nftAccessor.TransferFrom(0, rlm, owner, to, positionIdFrom(positionId))
457}
458
459// collectContext carries the values every reward delivery of one collect call shares: the deposit being
460// collected, the collect timestamp and height, the caller identity used in events, and the accumulator
461// snapshot emitted with every collect event.
462//
463// Resolving it once is what keeps a collect-everything call at a single snapshot: the emission delivery
464// and each incentive delivery all read the same context.
465type collectContext struct {
466 positionId uint64
467 deposit *sr.Deposit
468 depositResolver *DepositResolver
469
470 // checkpoint is set when the position was unstaked before collecting.
471 checkpoint *sr.UnstakedPosition
472
473 currentTime int64
474 blockHeight int64
475
476 prevAddr string
477 prevRealm string
478
479 stakedLiquidity *u256.Uint
480 globalAccX128 *u256.Uint
481 lowerOutsideAccX128 *u256.Uint
482 upperOutsideAccX128 *u256.Uint
483}
484
485// newCollectContext resolves the deposit and snapshots the accumulator values used for reward
486// calculation tracking. Call it AFTER the calculation's state update has been applied, so a lazily
487// created pool is already persisted.
488func (s *stakerV1) newCollectContext(_ int, rlm realm, target *collectTarget, currentTime, blockHeight int64) *collectContext {
489 positionId := target.positionId
490 deposit := target.deposit
491
492 pool, _ := s.getPools().Get(deposit.TargetPoolPath())
493 poolResolver := newPoolResolverWithExit(pool, target.checkpoint)
494 globalAccX128, stakedLiquidity := poolResolver.globalRewardRatioAccumulationAt(currentTime)
495
496 previousRealm := rlm.Previous()
497
498 return &collectContext{
499 positionId: positionId,
500 deposit: deposit,
501 depositResolver: NewDepositResolver(deposit),
502 checkpoint: target.checkpoint,
503
504 currentTime: currentTime,
505 blockHeight: blockHeight,
506
507 prevAddr: previousRealm.Address().String(),
508 prevRealm: previousRealm.PkgPath(),
509
510 stakedLiquidity: stakedLiquidity,
511 globalAccX128: globalAccX128,
512 lowerOutsideAccX128: poolResolver.outsideAccumulationAt(deposit.TickLower(), currentTime),
513 upperOutsideAccX128: poolResolver.outsideAccumulationAt(deposit.TickUpper(), currentTime),
514 }
515}
516
517// unstakingFee returns the fee rate this collect settles under: the live rate while staked,
518// and the rate pinned at exit for a checkpoint, whose window closed under it.
519func (self *collectContext) unstakingFee(s *stakerV1) uint64 {
520 if self.checkpoint != nil {
521 return self.checkpoint.UnstakingFee()
522 }
523
524 return s.GetUnstakingFee()
525}
526
527// persistDeposit writes the deposit back, unless it belongs to a checkpoint and is not there.
528func (self *collectContext) persistDeposit(s *stakerV1) {
529 if self.checkpoint != nil {
530 return
531 }
532
533 s.getDeposits().set(self.positionId, self.deposit)
534}
535
536// externalDeliveryOutcome is what deliverExternalIncentiveReward did with one incentive. The
537// checkpoint bookkeeping keys off it instead of re-deriving the decision from incentive state,
538// which a permissionless re-entering collect may already have changed.
539type externalDeliveryOutcome int
540
541const (
542 // externalDeliveryPaid: the incentive was debited, the cursor advanced and the reward moved.
543 externalDeliveryPaid externalDeliveryOutcome = iota
544 // externalDeliveryNothingOwed: the window yielded no reward and no penalty.
545 externalDeliveryNothingOwed
546 // externalDeliveryAlreadyDelivered: a checkpoint cursor already at the window's close, so the
547 // source is settled - by a collect earlier in the exit second, or by an outer call whose
548 // transfer this call re-entered from.
549 externalDeliveryAlreadyDelivered
550 // externalDeliveryDeferred: skipped without bookkeeping and still payable later - the incentive
551 // has not started, or the staker realm's balance of the reward token is short while staked.
552 externalDeliveryDeferred
553 // externalDeliveryUnpayable: skipped without bookkeeping and never payable from this record,
554 // because the incentive's reward amount only ever decreases.
555 externalDeliveryUnpayable
556)
557
558// markEmissionCollected records the emission collect on the checkpoint. Idempotent: a
559// re-entering collect may have marked it already.
560func (s *stakerV1) markEmissionCollected(_ int, rlm realm, target *collectTarget) {
561 if !target.isCheckpoint() || target.checkpoint.EmissionCollected() {
562 return
563 }
564
565 target.checkpoint.MarkEmissionCollected()
566 s.emitCheckpointSourceCollected(0, rlm, target, "emission", "")
567 s.dropUnstakedPositionIfCollected(0, rlm, target)
568}
569
570// settleHaltedEmission handles the emission source of a collect that ran while emission is halted.
571func (s *stakerV1) settleHaltedEmission(_ int, rlm realm, target *collectTarget, rewardParam *calculatePositionRewardParam) {
572 if !target.isCheckpoint() || target.checkpoint.EmissionCollected() {
573 return
574 }
575
576 internalRewards, _, err := s.calculateInternalPositionReward(rewardParam)
577 if err != nil {
578 panic(err)
579 }
580
581 internalReward := aggregateRewards(internalRewards)
582 if internalReward.Internal != 0 || internalReward.InternalPenalty != 0 {
583 return
584 }
585
586 // Mark the checkpoint's emission source as collected.
587 s.markEmissionCollected(0, rlm, target)
588}
589
590// markIncentiveCollected records the incentive collect on the checkpoint from the delivery's
591// reported outcome. A paid, empty or already-delivered source is done. A deferred one stays
592// pending. An unpayable one is forfeited: keeping it pending forever would lock both this
593// position's re-staking and the incentive's own refund, with no escape path.
594func (s *stakerV1) markIncentiveCollected(_ int, rlm realm, target *collectTarget, incentiveId string, rewardAmount, rewardPenalty int64, outcome externalDeliveryOutcome) {
595 if !target.isCheckpoint() || !target.checkpoint.HasPendingIncentiveId(incentiveId) {
596 return
597 }
598
599 switch outcome {
600 case externalDeliveryDeferred:
601 return
602 case externalDeliveryUnpayable:
603 incentiveResolver := NewExternalIncentiveResolver(s.getExternalIncentives().get(incentiveId))
604 previousRealm := rlm.Previous()
605 chain.Emit(
606 "ForfeitUncollectedIncentiveReward",
607 "prevAddr", previousRealm.Address().String(),
608 "prevRealm", previousRealm.PkgPath(),
609 "positionId", utils.FormatUint(target.positionId),
610 "incentiveId", incentiveId,
611 "rewardToken", incentiveResolver.RewardToken(),
612 "forfeitedAmount", utils.FormatInt(rewardAmount),
613 "forfeitedPenalty", utils.FormatInt(rewardPenalty),
614 "availableAmount", utils.FormatInt(incentiveResolver.RewardAmount()),
615 "currentTime", utils.FormatInt(time.Now().Unix()),
616 "currentHeight", utils.FormatInt(runtime.ChainHeight()),
617 )
618 }
619
620 target.checkpoint.MarkIncentiveCollected(incentiveId)
621 s.addUncollectedIncentiveCount(incentiveId, -1)
622 s.emitCheckpointSourceCollected(0, rlm, target, "external", incentiveId)
623 s.dropUnstakedPositionIfCollected(0, rlm, target)
624}
625
626// emitCheckpointSourceCollected reports one reward source of a checkpoint as settled, so an
627// indexer can follow what the checkpoint still owes without reading realm state.
628func (s *stakerV1) emitCheckpointSourceCollected(_ int, rlm realm, target *collectTarget, source, incentiveId string) {
629 previousRealm := rlm.Previous()
630 chain.Emit(
631 "CollectUnstakedPositionSource",
632 "prevAddr", previousRealm.Address().String(),
633 "prevRealm", previousRealm.PkgPath(),
634 "positionId", utils.FormatUint(target.positionId),
635 "poolPath", target.deposit.TargetPoolPath(),
636 "source", source,
637 "incentiveId", incentiveId,
638 "exitTime", utils.FormatInt(target.checkpoint.ExitTime()),
639 "emissionCollected", utils.FormatBool(target.checkpoint.EmissionCollected()),
640 "pendingIncentiveCount", utils.FormatInt(int64(target.checkpoint.PendingIncentiveCount())),
641 "currentTime", utils.FormatInt(time.Now().Unix()),
642 "currentHeight", utils.FormatInt(runtime.ChainHeight()),
643 )
644}
645
646// collectTarget is the record a collect runs against: the live deposit while staked, or the
647// exit checkpoint an unstake left behind.
648type collectTarget struct {
649 positionId uint64
650 deposit *sr.Deposit
651 // currentTime is now while staked, and the exit time for a checkpoint.
652 currentTime int64
653 checkpoint *sr.UnstakedPosition
654}
655
656// checkpointDeposit returns the deposit to calculate against when it is not in the tree.
657func (self *collectTarget) checkpointDeposit() *sr.Deposit {
658 if self.checkpoint == nil {
659 return nil
660 }
661
662 return self.deposit
663}
664
665// isCheckpoint reports whether the collect runs against an exit checkpoint.
666func (self *collectTarget) isCheckpoint() bool {
667 return self.checkpoint != nil
668}
669
670// resolveCollectTarget resolves what a collect runs against and asserts the caller may.
671func (s *stakerV1) resolveCollectTarget(positionId uint64, caller address) *collectTarget {
672 position := s.getUnstakedPositions().get(positionId)
673 if position == nil {
674 assertIsDepositor(s, caller, positionId)
675
676 return &collectTarget{
677 positionId: positionId,
678 deposit: s.getDeposits().get(positionId),
679 currentTime: time.Now().Unix(),
680 }
681 }
682
683 // A checkpoint collect is permissionless: it can only ever deliver to the position's owner,
684 // and an open entry point is what lets anyone clear the checkpoints that hold up
685 // EndExternalIncentive on the pool.
686 return &collectTarget{
687 positionId: positionId,
688 deposit: position.Deposit(),
689 currentTime: position.ExitTime(),
690 checkpoint: position,
691 }
692}
693
694// newRewardCollectParam builds the calculation input for a collect of the given position.
695func (s *stakerV1) newRewardCollectParam(target *collectTarget, currentTime, blockHeight int64) *calculatePositionRewardParam {
696 return &calculatePositionRewardParam{
697 CurrentHeight: blockHeight,
698 CurrentTime: currentTime,
699 Deposits: s.getDeposits(),
700 Pools: s.getPools(),
701 PoolTier: s.getPoolTier(),
702 PositionId: target.positionId,
703 Deposit: target.checkpointDeposit(),
704 Exit: target.checkpoint,
705 }
706}
707
708// CollectEmissionReward harvests only the GNS emission (internal) reward for a live staked deposit
709// or an exit checkpoint left by UnStakeToken.
710//
711// The emission and the external incentive rewards track their collection cursors independently
712// (a single lastCollectTime on the deposit for emission, one per incentive id for external), so
713// collecting one side leaves the other side fully accruable.
714//
715// State Transition:
716// 1. Emission is minted and distributed so the staker holds the GNS to pay out; when emission is
717// halted, collection returns without consuming the accrued reward
718// 2. Warm-up ratios are applied and the emission reward is split into user / penalty amounts
719// 3. GNS is transferred to the owner, penalties and unclaimable amounts to the community pool
720// 4. The deposit's internal lastCollectTime is advanced
721//
722// Requirements:
723// - Withdrawals must not be halted
724// - An emission halt returns zero without advancing internal reward state
725// - Caller must be the position owner for a live deposit; checkpoint collection is permissionless
726// - Position must be staked or have an exit checkpoint
727//
728// Parameters:
729// - _: Noncrossing implementation-call discriminator; pass 0.
730// - rlm: Current realm context forwarded unchanged by the staker proxy.
731// - positionId: LP position NFT token ID or position with an exit checkpoint.
732//
733// Returns:
734// - rewardToUser: GNS emission amount sent to the position owner, in token smallest units.
735// - rewardPenalty: GNS warmup penalty sent to the community pool, in token smallest units.
736func (s *stakerV1) CollectEmissionReward(_ int, rlm realm, positionId uint64) (int64, int64) {
737 access.AssertIsRlmCurrent(0, rlm)
738
739 halt.AssertIsNotHaltedWithdraw()
740
741 target := s.resolveCollectTarget(positionId, rlm.Previous().Address())
742
743 _, emissionActive := en.MintAndDistributeGns(cross(rlm))
744
745 currentTime := target.currentTime
746 blockHeight := runtime.ChainHeight()
747 rewardParam := s.newRewardCollectParam(target, currentTime, blockHeight)
748
749 if !emissionActive {
750 // Deferred: the accrued reward stays on the cursor until emission resumes. A checkpoint that
751 // owes no emission is still settled so the halt does not block re-staking.
752 s.settleHaltedEmission(0, rlm, target, rewardParam)
753
754 return 0, 0
755 }
756
757 // Calculation is read-only; the resulting state updates (reward-cache update, lazy pool persistence)
758 // are applied via updateInternalPositionReward, which is the collect-only counterpart to the
759 // calculation shared with the Collectable* view getters.
760 rewards, rewardUpdate, err := s.calculateInternalPositionReward(rewardParam)
761 if err != nil {
762 panic(err)
763 }
764 reward := aggregateRewards(rewards)
765 s.updateInternalPositionReward(rewardParam, rewardUpdate)
766
767 ctx := s.newCollectContext(0, rlm, target, currentTime, blockHeight)
768
769 rewardToUser, rewardPenalty := s.deliverEmissionReward(0, rlm, ctx, reward)
770
771 s.markEmissionCollected(0, rlm, target)
772
773 return rewardToUser, rewardPenalty
774}
775
776// CollectExternalIncentiveReward harvests one external incentive reward for a live staked deposit
777// or an exit checkpoint left by UnStakeToken.
778//
779// Each incentive keeps its own lastCollectTime, so the emission reward and every incentive other than
780// the requested one stay collectible afterwards. For a live deposit, a not-started, zero-reward, or
781// reward-token-balance-short incentive remains collectible because its cursor is not advanced. For
782// an exit checkpoint, an incentive-balance shortfall is forfeited to release the checkpoint rather
783// than left as an unpayable claim.
784//
785// State Transition:
786// 1. Incentives created since the deposit's last update are added to its incentive index
787// 2. Warm-up ratios are applied and the reward is split into user / penalty amounts
788// 3. The reward token is transferred to the owner, the penalty accumulates on the incentive
789// 4. The collected incentive's lastCollectTime is advanced
790//
791// Requirements:
792// - Contract must not be halted
793// - Caller must be the position owner for a live deposit; checkpoint collection is permissionless
794// - Position must be staked or have an exit checkpoint
795// - Incentive must exist and target the pool the position is staked in
796//
797// Parameters:
798// - _: Noncrossing implementation-call discriminator; pass 0.
799// - rlm: Current realm context forwarded unchanged by the staker proxy.
800// - positionId: LP position NFT token ID or position with an exit checkpoint.
801// - incentiveId: External incentive to collect.
802//
803// Returns:
804// - rewardAmount: Gross external reward amount delivered before the staking reward fee, in token smallest units.
805// - rewardPenalty: External warmup penalty retained by the incentive, in token smallest units.
806func (s *stakerV1) CollectExternalIncentiveReward(_ int, rlm realm, positionId uint64, incentiveId string) (int64, int64) {
807 access.AssertIsRlmCurrent(0, rlm)
808
809 halt.AssertIsNotHaltedWithdraw()
810
811 target := s.resolveCollectTarget(positionId, rlm.Previous().Address())
812 assertIsIncentiveOfPool(s, target.deposit.TargetPoolPath(), positionId, incentiveId)
813
814 currentTime := target.currentTime
815 blockHeight := runtime.ChainHeight()
816
817 // Calculation is read-only; the resulting state updates (lazy pool persistence, deposit
818 // incentive-index updates) are applied via updateExternalPositionReward, which is the collect-only
819 // counterpart to the calculation shared with the Collectable* view getters.
820 rewardParam := s.newRewardCollectParam(target, currentTime, blockHeight)
821 rewards, rewardUpdate := s.calculateExternalPositionReward(rewardParam, incentiveId)
822 reward := aggregateRewards(rewards)
823 s.updateExternalPositionReward(rewardParam, rewardUpdate)
824
825 ctx := s.newCollectContext(0, rlm, target, currentTime, blockHeight)
826
827 _, rewardAmount, rewardPenalty, outcome := s.deliverExternalIncentiveReward(
828 0, rlm, ctx, incentiveId, reward.External[incentiveId], reward.ExternalPenalty[incentiveId],
829 )
830
831 // Persist the deposit even when nothing was delivered: the incentive-index update above still ran.
832 ctx.persistDeposit(s)
833
834 s.markIncentiveCollected(0, rlm, target, incentiveId, reward.External[incentiveId], reward.ExternalPenalty[incentiveId], outcome)
835
836 return rewardAmount, rewardPenalty
837}
838
839// CollectReward harvests accumulated rewards for a live staked deposit or an exit checkpoint left
840// by UnStakeToken. This includes both internal GNS emission and external incentive rewards.
841//
842// It is the "collect everything" entry point kept for callers that do not want to choose a side. It
843// delivers each incentive, then the emission reward, through the same delivery functions the
844// single-source entry points use. During an emission halt, it skips internal calculation, state updates,
845// and delivery while continuing to settle external incentives.
846//
847// Requirements:
848// - Withdrawals must not be halted
849// - Caller must be the position owner for a live deposit; checkpoint collection is permissionless
850// - Position must be staked or have an exit checkpoint
851//
852// Parameters:
853// - _: Noncrossing implementation-call discriminator; pass 0.
854// - rlm: Current realm context forwarded unchanged by the staker proxy.
855// - positionId: LP position NFT token ID or position with an exit checkpoint.
856//
857// Returns:
858// - internalRewardToUser: GNS emission amount sent to the owner, formatted as a decimal string.
859// - internalRewardPenalty: GNS warmup penalty sent to the community pool, formatted as a decimal string.
860// - externalRewards: Map from reward-token path to gross external reward amounts before staking fees.
861// - externalPenalties: Map from reward-token path to external warmup penalty amounts.
862func (s *stakerV1) CollectReward(_ int, rlm realm, positionId uint64) (string, string, map[string]int64, map[string]int64) {
863 access.AssertIsRlmCurrent(0, rlm)
864
865 halt.AssertIsNotHaltedWithdraw()
866
867 target := s.resolveCollectTarget(positionId, rlm.Previous().Address())
868
869 _, emissionActive := en.MintAndDistributeGns(cross(rlm))
870
871 currentTime := target.currentTime
872 blockHeight := runtime.ChainHeight()
873 rewardParam := s.newRewardCollectParam(target, currentTime, blockHeight)
874
875 var (
876 rewards []Reward
877 rewardUpdate positionRewardUpdate
878 )
879
880 if emissionActive {
881 var err error
882
883 rewards, rewardUpdate, err = s.calculatePositionReward(rewardParam)
884 if err != nil {
885 panic(err)
886 }
887 s.updatePositionReward(rewardParam, rewardUpdate)
888 } else {
889 rewards, rewardUpdate = s.calculateExternalPositionRewards(rewardParam)
890 s.updateExternalPositionReward(rewardParam, rewardUpdate)
891 }
892
893 reward := aggregateRewards(rewards)
894
895 ctx := s.newCollectContext(0, rlm, target, currentTime, blockHeight)
896
897 // External rewards are delivered first, the order the two reward paths ran in before they were split.
898 toUserExternalReward := make(map[string]int64)
899 toUserExternalPenalty := make(map[string]int64)
900
901 for _, incentiveId := range rewardUpdate.externalIncentiveIds {
902 rewardToken, rewardAmount, rewardPenalty, outcome := s.deliverExternalIncentiveReward(
903 0, rlm, ctx, incentiveId, reward.External[incentiveId], reward.ExternalPenalty[incentiveId],
904 )
905
906 s.markIncentiveCollected(0, rlm, target, incentiveId, reward.External[incentiveId], reward.ExternalPenalty[incentiveId], outcome)
907
908 // An empty reward token means the incentive delivered nothing and stays collectible.
909 if rewardToken == "" {
910 continue
911 }
912
913 toUserExternalReward[rewardToken] = gnsmath.SafeAddInt64(toUserExternalReward[rewardToken], rewardAmount)
914 toUserExternalPenalty[rewardToken] = gnsmath.SafeAddInt64(toUserExternalPenalty[rewardToken], rewardPenalty)
915 }
916
917 internalRewardToUser := int64(0)
918 internalRewardPenalty := int64(0)
919
920 if emissionActive {
921 internalRewardToUser, internalRewardPenalty = s.deliverEmissionReward(0, rlm, ctx, reward)
922 s.markEmissionCollected(0, rlm, target)
923 } else {
924 s.settleHaltedEmission(0, rlm, target, rewardParam)
925
926 // The halted path does not reach deliverEmissionReward, which is where the live deposit's
927 // incentive-index and cursor updates were written back. A checkpoint is not in the tree, so
928 // this is a no-op for it.
929 ctx.persistDeposit(s)
930 }
931
932 return utils.FormatInt(internalRewardToUser), utils.FormatInt(internalRewardPenalty), toUserExternalReward, toUserExternalPenalty
933}
934
935// deliverExternalIncentiveReward pays out one incentive's already-calculated reward: it debits the
936// incentive, advances its lastCollectTime, takes the staking reward fee and transfers the rest.
937//
938// The incentive is skipped WITHOUT advancing its lastCollectTime when it yields no user reward or does
939// not hold enough reward token, so the amount stays collectible later. A skipped incentive is reported
940// by an empty reward token, and the outcome says why, so a checkpoint can tell a deferral from a debt
941// it will never be able to pay.
942//
943// A warmup ratio below 100% floors the user reward, so a small accrual can yield rewardAmount == 0 while
944// externalPenalty > 0. Such a call is skipped as a whole: the penalty is NOT booked and the cursor is NOT
945// advanced, which leaves the untouched accrual to be recalculated over the wider window of the next
946// collect. Nothing is lost either way, because an unbooked penalty stays in the incentive's reward
947// amount, and endExternalIncentive refunds the reward amount and the accumulated penalty to the same
948// address.
949//
950// Returns the reward token, the collected reward amount before the staking reward fee, the penalty,
951// and the delivery outcome.
952func (s *stakerV1) deliverExternalIncentiveReward(
953 _ int,
954 rlm realm,
955 ctx *collectContext,
956 incentiveId string,
957 rewardAmount int64,
958 externalPenalty int64,
959) (string, int64, int64, externalDeliveryOutcome) {
960 // A checkpoint's window closes at ctx.currentTime, so a cursor already there means this
961 // incentive was delivered. The checkpoint entry points are permissionless, and rewardAmount
962 // was computed before the deliveries ran: without this, a reentrant collect between them
963 // would let the stale amount be paid a second time.
964 if ctx.checkpoint != nil && ctx.depositResolver.ExternalRewardLastCollectTime(incentiveId) >= ctx.currentTime {
965 return "", 0, 0, externalDeliveryAlreadyDelivered
966 }
967
968 // Skip when user reward is zero.
969 // Do not update last collect time so the reward accrues until
970 // the next collection where a non-zero amount can be delivered.
971 //
972 // An exit checkpoint has no next collection: it stopped accruing, so a penalty left here
973 // would never be booked and would be dropped with the checkpoint.
974 if rewardAmount == 0 && (ctx.checkpoint == nil || externalPenalty == 0) {
975 if externalPenalty == 0 {
976 return "", 0, 0, externalDeliveryNothingOwed
977 }
978
979 return "", 0, 0, externalDeliveryDeferred
980 }
981
982 // get panics on a missing id; incentives are never removed from the tree.
983 incentive := s.getExternalIncentives().get(incentiveId)
984
985 incentiveResolver := NewExternalIncentiveResolver(incentive)
986
987 // Defensive backstop, unreachable through the current callers: computeExternalReward already drops a
988 // not-yet-started incentive at the same currentTime, so its reward is zero and the check above
989 // returns first. Kept so a future caller that supplies a non-zero amount cannot pay out of an
990 // incentive whose distribution window has not opened.
991 if !incentiveResolver.IsStarted(ctx.currentTime) {
992 return "", 0, 0, externalDeliveryDeferred
993 }
994
995 totalRewardAmount := gnsmath.SafeAddInt64(rewardAmount, externalPenalty)
996
997 if incentiveResolver.RewardAmount() < totalRewardAmount {
998 // Do not update last collect time here; insufficient funds should
999 // leave the incentive collectible when refilled or corrected.
1000 chain.Emit(
1001 "InsufficientExternalReward",
1002 "prevAddr", ctx.prevAddr,
1003 "prevRealm", ctx.prevRealm,
1004 "positionId", utils.FormatUint(ctx.positionId),
1005 "incentiveId", incentiveId,
1006 "requiredAmount", utils.FormatInt(totalRewardAmount),
1007 "availableAmount", utils.FormatInt(incentiveResolver.RewardAmount()),
1008 "currentTime", utils.FormatInt(ctx.currentTime),
1009 "currentHeight", utils.FormatInt(ctx.blockHeight),
1010 )
1011
1012 // The reward amount only ever decreases, so this shortfall cannot be corrected later.
1013 return "", 0, 0, externalDeliveryUnpayable
1014 }
1015
1016 // process reward states
1017 rewardToken := incentive.RewardToken()
1018
1019 // Ledger-level delivery guard (audit finding #4).
1020 //
1021 // The bookkeeping check above proves the incentive owes this reward; it says
1022 // nothing about whether the ledger transfers below can succeed. A panic there
1023 // would abort the collect: while staked that only delays the payout, but on an
1024 // exit checkpoint it would leave the checkpoint in place forever, holding the
1025 // position's re-staking and the incentive's refund hostage to a third-party token.
1026 //
1027 // GnoSwap transfers resolve through grc20reg's concrete *grc20.Token straight
1028 // into PrivateLedger, so no token-realm code runs in the path and the
1029 // reachable failure set is closed. Because grc20 Mint enforces
1030 // totalSupply <= MaxInt64 and every ledger operation conserves
1031 // sum(balances) == totalSupply, a recipient-balance overflow is unreachable;
1032 // the only reachable failure is the sender balance falling short of what
1033 // BOTH legs move (issuer burn of the staker's balance, or accounting drift).
1034 // The fee leg settles carried-over pending protocol fees of the same token
1035 // too, hence the +pending term. The full derivation, the trust model, and
1036 // the re-audit trigger live in docs/staker.md.
1037 //
1038 // Skipping BEFORE any bookkeeping means: while staked the reward simply
1039 // stays pending and becomes collectible once the balance is restored. A
1040 // checkpoint aborts instead: its claim is fixed and must not be waived by
1041 // a permissionless caller, so it stays owed until the balance is restored.
1042 stakerAddr := access.MustGetAddress(prbac.ROLE_STAKER.String())
1043 pendingProtocolFee := s.store.GetPendingProtocolFees()[rewardToken]
1044 requiredBalance := gnsmath.SafeAddInt64(totalRewardAmount, pendingProtocolFee)
1045
1046 if common.BalanceOf(rewardToken, stakerAddr) < requiredBalance {
1047 chain.Emit(
1048 "UndeliverableExternalReward",
1049 "prevAddr", ctx.prevAddr,
1050 "prevRealm", ctx.prevRealm,
1051 "positionId", utils.FormatUint(ctx.positionId),
1052 "incentiveId", incentiveId,
1053 "rewardToken", rewardToken,
1054 "requiredBalance", utils.FormatInt(requiredBalance),
1055 "stakerBalance", utils.FormatInt(common.BalanceOf(rewardToken, stakerAddr)),
1056 "currentTime", utils.FormatInt(ctx.currentTime),
1057 "currentHeight", utils.FormatInt(ctx.blockHeight),
1058 )
1059
1060 if ctx.checkpoint != nil {
1061 panic(makeErrorWithDetails(
1062 errInsufficientRewardTokenBalance,
1063 ufmt.Sprintf(
1064 "incentive(%s) owes %d of %s to positionId(%d) but the staker holds %d",
1065 incentiveId, requiredBalance, rewardToken, ctx.positionId, common.BalanceOf(rewardToken, stakerAddr),
1066 ),
1067 ))
1068 }
1069
1070 return "", 0, 0, externalDeliveryDeferred
1071 }
1072
1073 incentive.SetRewardAmount(gnsmath.SafeSubInt64(incentive.RewardAmount(), totalRewardAmount))
1074 incentiveResolver.addDistributedRewardAmount(rewardAmount)
1075 incentiveResolver.addAccumulatedPenaltyAmount(externalPenalty)
1076 ctx.depositResolver.addCollectedExternalReward(incentiveId, totalRewardAmount)
1077
1078 // Update the last collect time ONLY for this specific incentive
1079 // This happens only if the reward was successfully transferred.
1080 err := ctx.depositResolver.updateExternalRewardLastCollectTime(incentiveId, ctx.currentTime)
1081 if err != nil {
1082 panic(err)
1083 }
1084
1085 // If incentive ended and user already collected after end, remove from index
1086 // This ensures deposit's incentive list shrinks over time as incentives complete
1087 if ctx.depositResolver.ExternalRewardLastCollectTime(incentiveId) > incentiveResolver.EndTimestamp() {
1088 ctx.deposit.RemoveExternalIncentiveId(incentiveId)
1089 }
1090
1091 // update
1092 s.getExternalIncentives().set(incentiveId, incentive)
1093
1094 toUser, feeAmount, err := s.handleStakingRewardFee(0, rlm, rewardToken, rewardAmount, false, ctx.unstakingFee(s))
1095 if err != nil {
1096 panic(err.Error())
1097 }
1098
1099 if toUser > 0 {
1100 common.SafeGRC20Transfer(0, rlm, rewardToken, ctx.deposit.Owner(), toUser)
1101 }
1102
1103 chain.Emit(
1104 "ProtocolFeeExternalReward",
1105 "prevAddr", ctx.prevAddr,
1106 "prevRealm", ctx.prevRealm,
1107 "fromPositionId", utils.FormatUint(ctx.positionId),
1108 "fromPoolPath", incentive.TargetPoolPath(),
1109 "feeTokenPath", rewardToken,
1110 "feeAmount", utils.FormatInt(feeAmount),
1111 "currentTime", utils.FormatInt(ctx.currentTime),
1112 "currentHeight", utils.FormatInt(ctx.blockHeight),
1113 )
1114
1115 chain.Emit(
1116 "CollectReward",
1117 "prevAddr", ctx.prevAddr,
1118 "prevRealm", ctx.prevRealm,
1119 "positionId", utils.FormatUint(ctx.positionId),
1120 "poolPath", ctx.deposit.TargetPoolPath(),
1121 "recipient", ctx.deposit.Owner().String(),
1122 "incentiveId", incentiveId,
1123 "rewardToken", rewardToken,
1124 "rewardAmount", utils.FormatInt(rewardAmount),
1125 "rewardToUser", utils.FormatInt(toUser),
1126 "rewardToFee", utils.FormatInt(rewardAmount-toUser),
1127 "rewardPenalty", utils.FormatInt(externalPenalty),
1128 "currentTime", utils.FormatInt(ctx.currentTime),
1129 "currentHeight", utils.FormatInt(ctx.blockHeight),
1130 "stakedLiquidity", ctx.stakedLiquidity.ToString(),
1131 "globalRewardRatioAccX128", ctx.globalAccX128.ToString(),
1132 "lowerTickOutsideAccX128", ctx.lowerOutsideAccX128.ToString(),
1133 "upperTickOutsideAccX128", ctx.upperOutsideAccX128.ToString(),
1134 )
1135
1136 return rewardToken, rewardAmount, externalPenalty, externalDeliveryPaid
1137}
1138
1139// deliverEmissionReward pays out the already-calculated GNS emission reward: it takes the staking reward
1140// fee, settles the pool's unclaimable accumulation to the community pool, advances totalEmissionSent and
1141// the deposit's internal lastCollectTime, and transfers the GNS.
1142//
1143// When the position has no emission reward to deliver, the internal lastCollectTime is left untouched so
1144// the amount keeps accruing; the pool's unclaimable accumulation is still settled.
1145//
1146// Returns the GNS amount sent to the user and the penalty amount sent to the community pool.
1147func (s *stakerV1) deliverEmissionReward(_ int, rlm realm, ctx *collectContext, reward Reward) (int64, int64) {
1148 // Same reentrancy backstop as deliverExternalIncentiveReward: a checkpoint whose internal
1149 // cursor already reached its window's close has had this delivery, and reward was computed
1150 // before the external deliveries ran. The source counts as settled either way.
1151 if ctx.checkpoint != nil && ctx.depositResolver.InternalRewardLastCollectTime() >= ctx.currentTime {
1152 return 0, 0
1153 }
1154
1155 communityPoolAddr := access.MustGetAddress(prbac.ROLE_COMMUNITY_POOL.String())
1156
1157 internalReward := int64(0)
1158 internalRewardToUser := int64(0)
1159 internalRewardToFee := int64(0)
1160 internalRewardPenalty := int64(0)
1161
1162 // Skip internal reward state update when user reward is zero (only penalty).
1163 // Do not update last collect time so the reward accrues until the next
1164 // collection where a non-zero amount can be delivered.
1165 //
1166 // An exit checkpoint has no next collection: it stopped accruing, so a penalty left here
1167 // would never be delivered and would be dropped with the checkpoint.
1168 skipInternalUpdate := reward.Internal == 0 &&
1169 (ctx.checkpoint == nil || reward.InternalPenalty == 0)
1170
1171 // internal reward to user
1172 if !skipInternalUpdate {
1173 toUser, feeAmount, err := s.handleStakingRewardFee(0, rlm, GNS_TOKEN_KEY, reward.Internal, true, ctx.unstakingFee(s))
1174 if err != nil {
1175 panic(err.Error())
1176 }
1177
1178 internalReward = reward.Internal
1179 internalRewardToUser = toUser
1180 internalRewardToFee = feeAmount
1181 internalRewardPenalty = reward.InternalPenalty
1182
1183 chain.Emit(
1184 "ProtocolFeeInternalReward",
1185 "prevAddr", ctx.prevAddr,
1186 "prevRealm", ctx.prevRealm,
1187 "fromPositionId", utils.FormatUint(ctx.positionId),
1188 "fromPoolPath", ctx.deposit.TargetPoolPath(),
1189 "feeTokenPath", GNS_TOKEN_KEY,
1190 "feeAmount", utils.FormatInt(internalRewardToFee),
1191 "currentTime", utils.FormatInt(ctx.currentTime),
1192 "currentHeight", utils.FormatInt(ctx.blockHeight),
1193 )
1194 }
1195
1196 totalEmissionSent := s.store.GetTotalEmissionSent()
1197
1198 if internalRewardToUser > 0 {
1199 // internal reward to user
1200 totalEmissionSent = gnsmath.SafeAddInt64(totalEmissionSent, internalRewardToUser)
1201 ctx.depositResolver.addCollectedInternalReward(reward.Internal)
1202 }
1203
1204 if internalRewardPenalty > 0 {
1205 // internal penalty to community pool
1206 totalEmissionSent = gnsmath.SafeAddInt64(totalEmissionSent, internalRewardPenalty)
1207 ctx.depositResolver.addCollectedInternalReward(internalRewardPenalty)
1208 }
1209
1210 // Unclaimable must be processed after regular rewards so that accumulated
1211 // unclaimable amounts are reset in the same collect window.
1212 // Always at the current time, never at ctx.currentTime: this accumulator is pool-global, and
1213 // a checkpoint collect would rewind it to its exit timestamp, re-counting everything since as
1214 // unclaimable and paying it out of the shared reserve.
1215 unClaimableInternal := s.processUnClaimableReward(ctx.depositResolver.TargetPoolPath(), time.Now().Unix())
1216 if unClaimableInternal > 0 {
1217 totalEmissionSent = gnsmath.SafeAddInt64(totalEmissionSent, unClaimableInternal)
1218 }
1219
1220 if err := s.store.SetTotalEmissionSent(0, rlm, totalEmissionSent); err != nil {
1221 panic(err)
1222 }
1223
1224 if !skipInternalUpdate {
1225 // Update lastCollectTime for internal rewards (GNS emissions)
1226 if err := ctx.depositResolver.updateInternalRewardLastCollectTime(ctx.currentTime); err != nil {
1227 panic(err)
1228 }
1229 }
1230
1231 ctx.persistDeposit(s)
1232
1233 if internalRewardToUser > 0 {
1234 gns.Transfer(cross(rlm), ctx.deposit.Owner(), internalRewardToUser)
1235 }
1236
1237 if internalRewardPenalty > 0 {
1238 gns.Transfer(cross(rlm), communityPoolAddr, internalRewardPenalty)
1239 }
1240
1241 if unClaimableInternal > 0 {
1242 gns.Transfer(cross(rlm), communityPoolAddr, unClaimableInternal)
1243 }
1244
1245 if !skipInternalUpdate {
1246 chain.Emit(
1247 "CollectReward",
1248 "prevAddr", ctx.prevAddr,
1249 "prevRealm", ctx.prevRealm,
1250 "positionId", utils.FormatUint(ctx.positionId),
1251 "poolPath", ctx.depositResolver.TargetPoolPath(),
1252 "recipient", ctx.depositResolver.Owner().String(),
1253 "rewardToken", GNS_TOKEN_KEY,
1254 "rewardAmount", utils.FormatInt(internalReward),
1255 "rewardToUser", utils.FormatInt(internalRewardToUser),
1256 "rewardToFee", utils.FormatInt(internalRewardToFee),
1257 "rewardPenalty", utils.FormatInt(internalRewardPenalty),
1258 "rewardUnClaimableAmount", utils.FormatInt(unClaimableInternal),
1259 "currentTime", utils.FormatInt(ctx.currentTime),
1260 "currentHeight", utils.FormatInt(ctx.blockHeight),
1261 "stakedLiquidity", ctx.stakedLiquidity.ToString(),
1262 "globalRewardRatioAccX128", ctx.globalAccX128.ToString(),
1263 "lowerTickOutsideAccX128", ctx.lowerOutsideAccX128.ToString(),
1264 "upperTickOutsideAccX128", ctx.upperOutsideAccX128.ToString(),
1265 )
1266 }
1267
1268 return internalRewardToUser, internalRewardPenalty
1269}
1270
1271// UnStakeToken withdraws an LP token from staking and returns the NFT to its original owner.
1272// Rewards are not collected here: an exit checkpoint records what the position is still owed,
1273// and the Collect* entry points settle it per source.
1274//
1275// Parameters:
1276// - _: Noncrossing implementation-call discriminator; pass 0.
1277// - rlm: Current realm context forwarded unchanged by the staker proxy.
1278// - positionId: LP position NFT token ID to unstake.
1279//
1280// Process:
1281// 1. Records an exit checkpoint owing every reward source (GNS + external)
1282// 2. Transfers NFT ownership back to original owner
1283// 3. Clears position operator rights
1284// 4. Removes from reward tracking systems
1285// 5. Cleans up all staking metadata
1286//
1287// Returns:
1288// - poolPath: Pool identifier where position was staked.
1289//
1290// Requirements:
1291// - Caller must be the depositor
1292// - Position must be currently staked
1293func (s *stakerV1) UnStakeToken(_ int, rlm realm, positionId uint64) string { // poolPath
1294 access.AssertIsRlmCurrent(0, rlm)
1295
1296 caller := rlm.Previous().Address()
1297 halt.AssertIsNotHaltedWithdraw()
1298 assertIsDepositor(s, caller, positionId)
1299
1300 deposit := s.getDeposits().get(positionId)
1301
1302 // unStaked status
1303 poolPath := deposit.TargetPoolPath()
1304
1305 // record the exit checkpoint; collecting is left to the Collect* entry points
1306 checkpoint := s.recordUnstakedPosition(0, rlm, positionId, deposit, time.Now().Unix())
1307
1308 if err := s.applyUnStake(positionId); err != nil {
1309 panic(err)
1310 }
1311
1312 // transfer NFT ownership to origin owner
1313 stakerAddr := access.MustGetAddress(prbac.ROLE_STAKER.String())
1314 s.nftAccessor.TransferFrom(0, rlm, stakerAddr, deposit.Owner(), positionIdFrom(positionId))
1315 pn.SetPositionOperator(cross(rlm), positionId, ZERO_ADDRESS)
1316
1317 // get position information for event
1318 liquidity := getLiquidity(positionId)
1319 tickLower, tickUpper := getTickOf(positionId)
1320
1321 amount0, amount1 := s.calculateAmounts(poolPath, tickLower, tickUpper, liquidity)
1322
1323 // Get pool and accumulator values for reward calculation tracking
1324 currentTime := time.Now().Unix()
1325 pool, _ := s.getPools().Get(poolPath)
1326 poolResolver := NewPoolResolver(pool)
1327 currentTick := s.poolAccessor.GetSlot0Tick(poolPath)
1328
1329 globalAccX128, stakedLiquidity := poolResolver.globalRewardRatioAccumulationAt(currentTime)
1330
1331 previousRealm := rlm.Previous()
1332 chain.Emit(
1333 "UnStakeToken",
1334 "prevAddr", previousRealm.Address().String(),
1335 "prevRealm", previousRealm.PkgPath(),
1336 "positionId", utils.FormatUint(positionId),
1337 "poolPath", poolPath,
1338 "owner", deposit.Owner().String(),
1339 "liquidity", liquidity.ToString(),
1340 "positionUpperTick", utils.FormatInt(tickUpper),
1341 "positionLowerTick", utils.FormatInt(tickLower),
1342 "amount0", amount0.ToString(),
1343 "amount1", amount1.ToString(),
1344 "from", stakerAddr.String(),
1345 "to", deposit.Owner().String(),
1346 "currentTick", utils.FormatInt(currentTick),
1347 "stakedLiquidity", stakedLiquidity.ToString(),
1348 "globalRewardRatioAccX128", globalAccX128.ToString(),
1349 "exitTime", utils.FormatInt(checkpoint.ExitTime()),
1350 "pendingIncentiveIds", strings.Join(checkpoint.PendingIncentiveIdList(), ","),
1351 "pendingIncentiveCount", utils.FormatInt(int64(checkpoint.PendingIncentiveCount())),
1352 )
1353
1354 return poolPath
1355}
1356
1357func (s *stakerV1) applyUnStake(positionId uint64) error {
1358 deposit := s.getDeposits().get(positionId)
1359 depositResolver := NewDepositResolver(deposit)
1360 pool, ok := s.getPools().Get(depositResolver.TargetPoolPath())
1361 poolResolver := NewPoolResolver(pool)
1362 if !ok {
1363 return ufmt.Errorf(
1364 "%v: pool(%s) does not exist",
1365 errDataNotFound, depositResolver.TargetPoolPath(),
1366 )
1367 }
1368
1369 currentTime := time.Now().Unix()
1370 currentTick := s.poolAccessor.GetSlot0Tick(depositResolver.TargetPoolPath())
1371 signedLiquidity := i256.Zero().Neg(i256.FromUint256(depositResolver.Liquidity()))
1372 inRange, err := pn.IsInRange(positionId)
1373 if err != nil {
1374 return err
1375 }
1376 if inRange {
1377 poolResolver.modifyDeposit(signedLiquidity, currentTime, currentTick)
1378 }
1379
1380 upperTick := poolResolver.GetOrNewTick(depositResolver.TickUpper())
1381 NewTickResolver(upperTick).modifyDepositUpper(currentTime, signedLiquidity)
1382 pool.Ticks().SetTick(depositResolver.TickUpper(), upperTick)
1383
1384 lowerTick := poolResolver.GetOrNewTick(depositResolver.TickLower())
1385 NewTickResolver(lowerTick).modifyDepositLower(currentTime, signedLiquidity)
1386 pool.Ticks().SetTick(depositResolver.TickLower(), lowerTick)
1387
1388 s.getDeposits().remove(positionId)
1389
1390 return nil
1391}
1392
1393// poolHasIncentives checks if the pool has any stakeable incentives (internal or external).
1394// External eligibility includes active and future incentives.
1395func (s *stakerV1) poolHasIncentives(pool *sr.Pool) error {
1396 poolPath := pool.PoolPath()
1397 if s.getPoolTier().IsInternallyIncentivizedPool(poolPath) {
1398 return nil
1399 }
1400
1401 if !NewPoolResolver(pool).IsExternallyIncentivizedPool() {
1402 return ufmt.Errorf(
1403 "%v: cannot stake position to non incentivized pool(%s)",
1404 errNonIncentivizedPool, poolPath,
1405 )
1406 }
1407
1408 return nil
1409}
1410
1411// tokenHasLiquidity checks if the target positionId has non-zero liquidity
1412func tokenHasLiquidity(positionId uint64) error {
1413 if getLiquidity(positionId).Lte(u256.Zero()) {
1414 return ufmt.Errorf(
1415 "%v: positionId(%d) has no liquidity",
1416 errZeroLiquidity, positionId,
1417 )
1418 }
1419 return nil
1420}
1421
1422func getLiquidity(positionId uint64) *u256.Uint {
1423 liquidity, err := pn.GetPositionLiquidity(positionId)
1424 if err != nil {
1425 panic(err)
1426 }
1427
1428 return u256.MustFromDecimal(liquidity)
1429}
1430
1431func getTickOf(positionId uint64) (int32, int32) {
1432 tickLower, err := pn.GetPositionTickLower(positionId)
1433 if err != nil {
1434 panic(err)
1435 }
1436 tickUpper, err := pn.GetPositionTickUpper(positionId)
1437 if err != nil {
1438 panic(err)
1439 }
1440 if tickUpper < tickLower {
1441 panic(ufmt.Sprintf("tickUpper(%d) is less than tickLower(%d)", tickUpper, tickLower))
1442 }
1443 return tickLower, tickUpper
1444}
1445
1446// calculateAmounts calculates the amounts of token0 and token1 for a given liquidity and range.
1447func (s *stakerV1) calculateAmounts(poolPath string, tickLower, tickUpper int32, liquidity *u256.Uint) (*u256.Uint, *u256.Uint) {
1448 sqrtPriceX96 := u256.MustFromDecimal(s.poolAccessor.GetSlot0SqrtPriceX96(poolPath))
1449 sqrtPriceLowerX96 := gnsmath.TickMathGetSqrtRatioAtTick(tickLower)
1450 sqrtPriceUpperX96 := gnsmath.TickMathGetSqrtRatioAtTick(tickUpper)
1451
1452 return gnsmath.GetAmountsForLiquidity(sqrtPriceX96, sqrtPriceLowerX96, sqrtPriceUpperX96, liquidity)
1453}