Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}