Transaction

A24FEFBD5FE71F…ACFC285CB567

Block 77,267 · index 1 · indexed

Summary

Hash
A24FEFBD5FE71FA746170BD3D6F50CA715E205D5561A5385EF66ACFC285CB567
Block
77,267
Size
282201 bytes
Gas used
353,110,842 / 423,732,951
Fee
423733 ugnot
Status
success

Messages

#1AddPackagegno.land/r/gnoswap/staker/v124 arguments

Arguments · 24

  1. #1staker
  2. #2README.md
  3. #3# Staker Liquidity mining and reward distribution for LP positions. ## Overview Staker manages distribution of internal (GNS emission) and external (caller-funded) rewards to staked LP positions, with time-weighted rewards and warmup periods. ## Configuration - **Deposit GNS Amount**: 100,000 GNS per external incentive (default; governance-adjustable) - **Minimum Reward Amount**: 1,000 token units (default for external incentive creation) - **Unstaking Fee**: 1% default (100 basis points; configurable from 0 to 10%) - **Internal Pool Tiers**: 1, 2, or 3 (assigned per pool); external-only pools can also be stakeable - **Warmup Schedule**: 30/50/70/100% over default cumulative windows of 0-5, 5-15, 15-45, and 45+ days - **External Token Policy**: Approved reward tokens; pool-pair tokens are also accepted for their own pool unless explicitly denied - **External Incentive Start**: UTC midnight, from the first eligible start (at least 24 hours after creation) through 7 days later ## Core Features ### Internal Rewards (GNS Emission) - Allocated to tiered pools (tiers 1, 2, 3) - Split across tiers by TierRatio - Distributed proportionally to in-range liquidity - Unclaimed rewards go to community pool ### External Rewards (Caller-Funded Incentives) - Created for specific pools by any caller that satisfies the token, duration, start-time, reward-minimum, and GNS-deposit checks - Constant reward per second over the configured incentive window; the stored rate is Q128-scaled as `(rewardAmount << 128) / duration` - Proportional to staked liquidity - `EndExternalIncentive` is creator/admin-only and sends only the unclaimable/remainder portion plus the GNS deposit to its explicit `refundAddress`; rewards still owed by live positions remain claimable - Accumulated warmup penalties are collected separately through `CollectExternalIncentivePenalty` after `EndExternalIncentive` ### Warmup Periods Every staked position progresses through warmup periods. The default finite durations are 5, 10, and 30 days, followed by a final `math.MaxInt64` tier: - 0-5 days: 30% of the calculated reward - 5-15 days: 50% of the calculated reward - 15-45 days: 70% of the calculated reward - 45+ days: 100% of the calculated reward Governance may change the finite durations. Warmup ratios are applied before the staking-reward fee: internal GNS penalties go to the community pool, while external penalties accumulate on the incentive and are collected separately to an explicit address after `EndExternalIncentive`. ## Key Functions ### `StakeToken` Stakes LP position NFT to earn rewards. ### `UnStakeToken` Unstakes the position and creates an exit checkpoint. It returns the NFT and the staked pool path without collecting rewards; use a `Collect*` entry point afterward. ### `CollectReward` Collects accumulated rewards for a live staked position or an exit checkpoint. Live collection requires the depositor/owner; checkpoint collection is permissionless and pays its pinned owner. ### `CreateExternalIncentive` Creates an external reward program for a specific pool. Creation is permissionless after all reward-token allowlist/denial, duration, start-time, reward-minimum, and GNS-deposit checks pass. ### `EndExternalIncentive` Ends an incentive after its end timestamp and finalizes its refundable unclaimable/remainder amount. The reward tokens and GNS deposit are sent to the caller-supplied `refundAddress`; only the creator or admin may call it, and an outstanding exit checkpoint for that incentive blocks ending. ### `CollectExternalIncentivePenalty` Collects accumulated warmup penalties for an incentive after `EndExternalIncentive` has finalized it, sending the penalty to the caller-supplied `refundAddress`. Only the creator or admin may call. ### `CancelExternalIncentive` Removes a not-yet-started incentive and refunds its reward tokens and GNS deposit to the creator. Callable by admin, governance, or the creator; the reward-token refund is capped by the staker balance. ## Reward Calculation Logic ### Tier Ratio Distribution Emission split across tiers based on active pools: ``` If only tier 1 has pools: [100%, 0%, 0%] If tiers 1 & 3 have pools: [80%, 0%, 20%] If tiers 1 & 2 have pools: [70%, 30%, 0%] If all tiers have pools: [50%, 30%, 20%] ``` Mathematical representation: ```math TierRatio(t) = [100, 0, 0] if Count(2) = 0 ∧ Count(3) = 0 [80, 0, 20] if Count(2) = 0 [70, 30, 0] if Count(3) = 0 [50, 30, 20] otherwise ``` ### Pool Reward Formula ```math poolReward(pool) = (emission × TierRatio[tier(pool)] / 100) / Count(tier(pool)) ``` Where emission is calculated as: ```math emission = GetStakerEmissionAmountPerSecond() ``` ### Position Reward Calculation The reward for each position is calculated through: 1. **Resolve the persisted/halving per-second reward schedule** (read-only) 2. **Retrieve position state** from deposit records or an exit checkpoint 3. **Calculate internal rewards** if the pool has an internal tier 4. **Calculate external rewards** for the incentive IDs 5. **Apply warmup ratios and penalties** based on stake duration Collection may separately advance reward caches and persist newly discovered incentive IDs; the read-only calculation itself does not write those caches. Mathematical formula for total reward ratio: ```math TotalRewardRatio(s,e) = Σ[i=0 to m-1] ΔRaw(αᵢ, βᵢ) × rᵢ where: αᵢ = max(s, Hᵢ₋₁) βᵢ = min(e, Hᵢ) ΔRaw(a, b) = CalcRaw(b) - CalcRaw(a) CalcRaw(h) = L(h) - U(h) if tick(h) < ℓ U(h) - L(h) if tick(h) ≥ u G(h) - (L(h) + U(h)) otherwise where: L(h) = tickLower.OutsideAccumulation(h) U(h) = tickUpper.OutsideAccumulation(h) G(h) = globalRewardRatioAccumulation(h) ℓ = tickLower.id u = tickUpper.id ``` Final position reward: ```math finalReward = TotalRewardRatio × poolReward × positionLiquidity = ∫[s to e] (poolReward × positionLiquidity) / TotalStakedLiquidity(h) dh ``` ### Tick Cross Hook When price crosses an initialized tick with staked positions: 1. **Updates staked liquidity** - Adjusts total staked liquidity 2. **Updates reward accumulation** - Recalculates `globalRewardRatioAccumulation` 3. **Manages unclaimable periods** - Starts/ends periods with no in-range liquidity 4. **Updates tick accumulation** - Adjusts `CurrentOutsideAccumulation` The `globalRewardRatioAccumulation` tracks the integral: ```math globalRewardRatioAccumulation = ∫ 1/TotalStakedLiquidity(h) dh ``` This integral is only computed when `TotalStakedLiquidity(h) ≠ 0`, enabling precise reward calculation even as liquidity changes. ### Reward State Tracking The system maintains: - **Global accumulation**: Tracks reward ratio across all positions - **Tick accumulation**: Tracks rewards "outside" each tick - **Position state**: Individual reward calculation parameters ## Approval Requirements - `StakeToken` moves the position NFT to the staker realm through `gnft.TransferFrom`, so the caller must approve the staker on that NFT first with `gnft.Approve(cross(cur), stakerAddress, positionId)`, or grant `gnft.SetApprovalForAll(cross(cur), stakerAddress, true)`. - `CreateExternalIncentive` pulls two amounts into the staker realm: the reward token amount and the GNS deposit. Approve the staker realm for both token contracts before calling. - `UnStakeToken`, the reward-collection functions, `EndExternalIncentive`, and `CancelExternalIncentive` pay out to the caller or to a supplied address and require no approval. ```go // Approve the staker on the position NFT, then stake stakerAddress := access.MustGetAddress(prabc.ROLE_STAKER.String()) gnft.Approve(cross(cur), stakerAddress, grc721.TokenID("123")) StakeToken(cross(cur), 123, "") // GNS pays both the external reward and the required deposit gns.Approve(cross(cur), stakerAddress, 1_000_000_000+GetDepositGnsAmount()) ``` ## Usage The proxy functions receive a realm argument. From a caller realm with `cur realm`, pass `cross(cur)` as that first argument: ```go // Stake an existing position StakeToken(cross(cur), 123, "g1referrer...") // Create an external incentive (rewardAmount is an int64 token-unit amount) CreateExternalIncentive( cross(cur), "gno.land/r/gnoland/wugnot.wugnot:gno.land/r/gnoswap/gns.GNS:3000", "gno.land/r/gnoswap/gns.GNS", 1_000_000_000, startTime, endTime, ) // Collect while the position is staked CollectReward(cross(cur), 123) // Unstake: this returns the NFT and creates an exit checkpoint; it does not collect UnStakeToken(cross(cur), 123) // Collect the checkpoint, either per source or all at once CollectEmissionReward(cross(cur), 123) CollectExternalIncentiveReward(cross(cur), 123, incentiveId) // End an incentive after its end timestamp, then collect accumulated penalties EndExternalIncentive(cross(cur), poolPath, incentiveId, refundAddress) CollectExternalIncentivePenalty(cross(cur), poolPath, incentiveId, refundAddress) ``` ## Security - Positions locked during staking - External incentive creation is permissionless after validation - External incentives require GNS deposit - Warmup periods prevent gaming - Unclaimed rewards properly redirected - Hook integration ensures accurate tracking
  4. #4assert.gno
  5. #5package staker import ( "math" "strconv" "strings" "time" prbac "gno.land/p/gnoswap/rbac/v1" ufmt "gno.land/p/nt/ufmt/v0" "gno.land/r/gnoswap/access/v1" ) const ( TIMESTAMP_90DAYS = int64(7776000) TIMESTAMP_180DAYS = int64(15552000) TIMESTAMP_365DAYS = int64(31536000) maxIncentiveStartDelay = 7 * 24 * time.Hour MAX_UNIX_EPOCH_TIME = 253402300799 // 9999-12-31 23:59:59 maxUnstakingFee = uint64(1000) // 10% ) // assertIsValidAmount ensures the amount is non-negative. func assertIsValidAmount(amount int64) { if amount < 0 { panic(makeErrorWithDetails( errInvalidInput, ufmt.Sprintf("amount(%d) must be positive", amount), )) } } // assertIsValidRewardAmountFormat ensures the reward amount string is formatted as "tokenPath:amount". func assertIsValidRewardAmountFormat(rewardAmountStr string) { parts := strings.SplitN(rewardAmountStr, ":", 2) if len(parts) != 2 { panic(makeErrorWithDetails( errInvalidInput, ufmt.Sprintf("invalid format for SetTokenMinimumRewardAmount params: expected 'tokenPath:amount', got '%s'", rewardAmountStr), )) } } // assertIsDepositor ensures the caller is the owner of the deposit. func assertIsDepositor(s *stakerV1, caller address, positionId uint64) { deposit := s.getDeposits().get(positionId) if deposit == nil { panic(makeErrorWithDetails( errDataNotFound, ufmt.Sprintf("positionId(%d) not found", positionId), )) } if caller != deposit.Owner() { panic(makeErrorWithDetails( errNoPermission, ufmt.Sprintf("caller(%s) is not depositor(%s)", caller.String(), deposit.Owner().String()), )) } } // assertIsIncentiveOfDeposit ensures the incentive exists and targets the pool the position is staked // in, so a caller cannot ask a deposit to collect an incentive that can never pay it. // assertIsIncentiveOfPool ensures the incentive targets the pool the position is staked in. func assertIsIncentiveOfPool(s *stakerV1, poolPath string, positionId uint64, incentiveId string) { // get panics with errDataNotFound when the incentive does not exist. incentive := s.getExternalIncentives().get(incentiveId) if incentive.TargetPoolPath() != poolPath { panic(makeErrorWithDetails( errInvalidInput, ufmt.Sprintf( "incentiveId(%s) targets pool(%s), but positionId(%d) is staked in pool(%s)", incentiveId, incentive.TargetPoolPath(), positionId, poolPath, ), )) } } // assertHasNoExitCheckpoint ensures the position carries no reward from a previous stake. // Collecting it here would smuggle an unbounded calculation into staking, and leaving it would // let the next unstake overwrite the checkpoint. func assertHasNoExitCheckpoint(s *stakerV1, positionId uint64) { if s.HasUnstakedPosition(positionId) { panic(makeErrorWithDetails( errUncollectedPosition, ufmt.Sprintf("positionId(%d) cannot be staked while its exit checkpoint owes rewards; collect after emission resumes if emission is halted", positionId), )) } } // assertHasNoUncollectedIncentive ensures no unstaked position still owes a reward from this // incentive, which has not been drawn down from it yet. func assertHasNoUncollectedIncentive(s *stakerV1, incentiveId string) { count := s.uncollectedIncentiveCountOf(incentiveId) if count > 0 { panic(makeErrorWithDetails( errUncollectedPosition, ufmt.Sprintf("incentive(%s) is owed to %d unstaked position(s)", incentiveId, count), )) } } // assertIsNotStaked ensures the position is not already staked. func assertIsNotStaked(s *stakerV1, positionId uint64) { if s.getDeposits().Has(positionId) { panic(makeErrorWithDetails( errAlreadyStaked, ufmt.Sprintf("positionId(%d) already staked", positionId), )) } } // assertIsPoolExists ensures the pool exists. func assertIsPoolExists(s *stakerV1, poolPath string) { if !s.poolAccessor.ExistsPoolPath(poolPath) { panic(makeErrorWithDetails( errInvalidPoolPath, ufmt.Sprintf("pool(%s) does not exist", poolPath), )) } } // assertIsValidPoolTier ensures the tier is within valid range. func assertIsValidPoolTier(tier uint64) { if tier >= AllTierCount { panic(makeErrorWithDetails( errInvalidPoolTier, ufmt.Sprintf("tier(%d) must be less than %d", tier, AllTierCount), )) } } // assertTier1HasSparePool ensures tier 1 keeps at least one pool after a tier change. func assertTier1HasSparePool(currentTier, tier1Count uint64) { if currentTier == Tier1 && tier1Count == 1 { panic(makeErrorWithDetails(errInvalidPoolTier, "tier 1 must have at least one pool")) } } // assertIsGreaterThanMinimumRewardAmount ensures the reward amount meets minimum requirements. func assertIsGreaterThanMinimumRewardAmount(s *stakerV1, rewardToken string, rewardAmount int64) { minReward := s.getMinimumRewardAmount() if minRewardInt64, found := s.store.GetTokenSpecificMinimumRewards()[rewardToken]; found { minReward = minRewardInt64 } if rewardAmount < minReward { panic(makeErrorWithDetails( errInvalidInput, ufmt.Sprintf("rewardAmount(%d) is less than minimum required amount(%d)", rewardAmount, minReward), )) } } // assertIsAllowedForExternalReward ensures the token is allowed for external rewards. func assertIsAllowedForExternalReward(s *stakerV1, poolPath, tokenPath string) { // Operational stop switch: a denied token can never start a NEW incentive, // whether it qualifies as a pool-pair token or through the governance // allowlist. Collection of already-created incentives is deliberately // unaffected; the delivery guard in deliverExternalIncentiveReward bounds // that blast radius instead. if contains(s.store.GetDeniedRewardTokens(), tokenPath) { panic(makeErrorWithDetails( errDeniedRewardToken, ufmt.Sprintf("tokenPath(%s) is denied as an external reward token", tokenPath), )) } token0, token1, _ := poolPathDivide(poolPath) if tokenPath == token0 || tokenPath == token1 { return } allowed := contains(s.store.GetAllowedTokens(), tokenPath) if allowed { return } panic(makeErrorWithDetails( errNotAllowedForExternalReward, ufmt.Sprintf("tokenPath(%s) is not allowed for external reward for poolPath(%s)", tokenPath, poolPath), )) } // assertIsExternalRewardTokenAvailable ensures a pool has no unfinished incentive for the token. func assertIsExternalRewardTokenAvailable(s *stakerV1, poolPath, tokenPath string, currentTime int64) { pool := s.getPools().GetPoolOrNil(poolPath) if pool == nil || pool.Incentives() == nil { return } minimumActiveIncentiveStartTime := currentTime - TIMESTAMP_365DAYS if minimumActiveIncentiveStartTime < 0 { minimumActiveIncentiveStartTime = 0 } pool.Incentives().IterateIncentiveIdsByTime(minimumActiveIncentiveStartTime, math.MaxInt64, func(incentiveID string) bool { incentive := s.getExternalIncentives().get(incentiveID) if incentive == nil { return false } if incentive.RewardToken() != tokenPath || NewExternalIncentiveResolver(incentive).IsEnded(currentTime) { return false } panic(makeErrorWithDetails( errIncentiveAlreadyExists, ufmt.Sprintf( "rewardToken(%s) already has unfinished incentive(%s) for poolPath(%s)", tokenPath, incentiveID, poolPath, ), )) }) } // assertIsValidFeeRate ensures the fee rate is within valid range (0-1000 basis points). func assertIsValidFeeRate(fee uint64) { if fee > maxUnstakingFee { panic(makeErrorWithDetails( errInvalidUnstakingFee, ufmt.Sprintf("fee(%d) must be in range 0 ~ %d", fee, maxUnstakingFee), )) } } // assertIsValidIncentiveStartTime ensures the incentive starts at midnight no earlier than 24 hours after creation // and no later than 7 days after the first eligible midnight. func assertIsValidIncentiveStartTime(startTimestamp int64) { // must be in seconds format, not milliseconds // REF: https://stackoverflow.com/a/23982005 numStr := strconv.Itoa(int(startTimestamp)) if len(numStr) >= 13 { panic(makeErrorWithDetails( errInvalidIncentiveStartTime, ufmt.Sprintf("startTimestamp(%d) must be in seconds format, not milliseconds", startTimestamp), )) } // must be at least 24 hours from now minimumStartTimestamp := getMinimumIncentiveStartTimestamp() if startTimestamp < minimumStartTimestamp { panic(makeErrorWithDetails( errInvalidIncentiveStartTime, ufmt.Sprintf("startTimestamp(%d) must be at least 24 hours later", startTimestamp), )) } maximumStartTimestamp := getMaximumIncentiveStartTimestamp() if startTimestamp > maximumStartTimestamp { panic(makeErrorWithDetails( errInvalidIncentiveStartTime, ufmt.Sprintf("startTimestamp(%d) exceeds the maximum start time policy", startTimestamp), )) } // must be midnight of the day startTime := time.Unix(startTimestamp, 0) if !isMidnight(startTime) { panic(makeErrorWithDetails( errInvalidIncentiveStartTime, ufmt.Sprintf("startTime(%d = %s) must be midnight of the day", startTimestamp, startTime.String()), )) } } // assertIsAdminGovernanceOrCreator ensures the caller may act on an incentive: // admin and governance are the protocol-level operators, and the creator is the // address that funded the incentive in CreateExternalIncentive. func assertIsAdminGovernanceOrCreator(caller, creator address) { if caller == creator { return } if access.IsAuthorized(prbac.ROLE_ADMIN.String(), caller) || access.IsAuthorized(prbac.ROLE_GOVERNANCE.String(), caller) { return } panic(makeErrorWithDetails( errNoPermission, ufmt.Sprintf( "caller(%s) must be admin, governance, or the incentive creator(%s)", caller.String(), creator.String(), ), )) } // assertIsNotStartedIncentive ensures the incentive can still be cancelled, i.e. // it has not started and nothing has been distributed from it. Only such an // incentive may be removed outright; anything that already emitted rewards has // to be finalized through EndExternalIncentive so the refund accounting and the // penalty balance stay reachable. func assertIsNotStartedIncentive(incentiveResolver *ExternalIncentiveResolver, currentTime int64) { if incentiveResolver.IsStarted(currentTime) { panic(makeErrorWithDetails( errCannotCancelIncentive, ufmt.Sprintf( "incentive(%s) already started at %d, current(%d)", incentiveResolver.IncentiveId(), incentiveResolver.StartTimestamp(), currentTime, ), )) } if incentiveResolver.Refunded() { panic(makeErrorWithDetails( errCannotCancelIncentive, ufmt.Sprintf("incentive(%s) has already been refunded", incentiveResolver.IncentiveId()), )) } // Defensive: a not-yet-started incentive can never have distributed a reward // or accrued a warmup penalty. Removing a record that carries either would // strand those tokens in the staker, so refuse instead of dropping it. if incentiveResolver.DistributedRewardAmount() != 0 || incentiveResolver.AccumulatedPenaltyAmount() != 0 { panic(makeErrorWithDetails( errCannotCancelIncentive, ufmt.Sprintf( "incentive(%s) has already accrued rewards: distributed(%d), penalty(%d)", incentiveResolver.IncentiveId(), incentiveResolver.DistributedRewardAmount(), incentiveResolver.AccumulatedPenaltyAmount(), ), )) } } // getMinimumIncentiveStartTimestamp returns after 24 hours from now. func getMinimumIncentiveStartTimestamp() int64 { return time.Now().Add(24 * time.Hour).Unix() } // getMaximumIncentiveStartTimestamp returns 7 days after the first midnight that satisfies // the minimum 24-hour delay. func getMaximumIncentiveStartTimestamp() int64 { minimumStartTimestamp := getMinimumIncentiveStartTimestamp() firstEligibleMidnight := time.Unix(minimumStartTimestamp, 0).Truncate(24 * time.Hour) if firstEligibleMidnight.Unix() < minimumStartTimestamp { firstEligibleMidnight = firstEligibleMidnight.Add(24 * time.Hour) } return firstEligibleMidnight.Add(maxIncentiveStartDelay).Unix() } // assertIsValidIncentiveEndTime ensures the end timestamp is within valid epoch range. func assertIsValidIncentiveEndTime(endTimestamp int64) { if endTimestamp >= MAX_UNIX_EPOCH_TIME { panic(makeErrorWithDetails( errInvalidInput, ufmt.Sprintf("endTimestamp(%d) cannot be later than 253402300799 (9999-12-31 23:59:59)", endTimestamp), )) } } // assertIsValidIncentiveDuration ensures the duration is 90, 180, or 365 days. func assertIsValidIncentiveDuration(externalDuration int64) { switch externalDuration { case TIMESTAMP_90DAYS, TIMESTAMP_180DAYS, TIMESTAMP_365DAYS: return } panic(makeErrorWithDetails( errInvalidIncentiveDuration, ufmt.Sprintf("externalDuration(%d) must be 90, 180, 365 days", externalDuration), )) } // AssertIsValidAddress panics if the provided address is invalid. func assertIsValidAddress(addr address) { if addr == "" || !addr.IsValid() { panic(makeErrorWithDetails( errInvalidAddress, ufmt.Sprintf("address(%s) is invalid", addr.String()), )) } } // isMidnight checks if a time represents midnight (00:00:00). func isMidnight(startTime time.Time) bool { hour := startTime.Hour() minute := startTime.Minute() second := startTime.Second() return hour == 0 && minute == 0 && second == 0 } // assertIsPositionOwner validates that the caller has permission to operate the token. func assertIsPositionOwner(owner, caller address) { if owner != caller { panic(makeErrorWithDetails( errNoPermission, ufmt.Sprintf("caller(%s) is not owner of positionId(%s)", caller, owner), )) } }
  6. #6calculate_pool_position_reward.gno
  7. #7package staker import ( "time" "gno.land/p/gnoswap/gnsmath/v1" ufmt "gno.land/p/nt/ufmt/v0" sr "gno.land/r/gnoswap/staker" ) // Reward is a struct for storing reward for a position. // Internal reward is the GNS reward, external reward is the reward for other incentives. // Penalties are the amount that is deducted from the reward due to the position's warmup. type Reward struct { Internal int64 InternalPenalty int64 External map[string]int64 // Incentive ID -> TokenAmount ExternalPenalty map[string]int64 // Incentive ID -> TokenAmount } // aggregateRewards sums the per-warmup rewards/penalties into a single Reward. func aggregateRewards(rewards []Reward) Reward { internal := int64(0) internalPenalty := int64(0) rewardLen := len(rewards) externalReward := make(map[string]int64, rewardLen) externalPenalty := make(map[string]int64, rewardLen) for _, reward := range rewards { internal = gnsmath.SafeAddInt64(internal, reward.Internal) internalPenalty = gnsmath.SafeAddInt64(internalPenalty, reward.InternalPenalty) for incentive, amount := range reward.External { externalReward[incentive] = gnsmath.SafeAddInt64(externalReward[incentive], amount) } for incentive, penalty := range reward.ExternalPenalty { externalPenalty[incentive] = gnsmath.SafeAddInt64(externalPenalty[incentive], penalty) } } return Reward{ Internal: internal, InternalPenalty: internalPenalty, External: externalReward, ExternalPenalty: externalPenalty, } } // calculatePositionRewardParam is a struct for calculating position reward type calculatePositionRewardParam struct { // Environmental variables CurrentHeight int64 CurrentTime int64 Deposits *Deposits Pools *Pools PoolTier *PoolTier // Position variables PositionId uint64 // Deposit overrides the Deposits lookup for a deposit that is no longer in the tree. Deposit *sr.Deposit // Exit pins the pool state to the moment the position left the pool; nil while staked. Exit *sr.UnstakedPosition } // deposit resolves the deposit the calculation runs against. func (self *calculatePositionRewardParam) deposit() *sr.Deposit { if self.Deposit != nil { return self.Deposit } return self.Deposits.get(self.PositionId) } // positionRewardUpdate carries the persisted-state changes produced (but not applied) by a calculation. // They are applied by the update* functions, so that only the collect path mutates state while the // calculation stays pure. type positionRewardUpdate struct { poolPath string pool *sr.Pool poolExisted bool // External incentive ids discovered as newly created since the deposit's last update; they must be // added to the deposit's incentive index. newExternalIncentiveIds []string // Whether the deposit's LastExternalIncentiveUpdatedAt cursor should be advanced to CurrentTime. advanceExternalIncentiveCursor bool // Incentive ids the calculation actually produced a reward for, in calculation order. The collect // path walks this instead of iterating the reward map, so delivery order is deterministic. externalIncentiveIds []string } // positionRewardContext carries the per-position state that both the internal (GNS emission) and the // external (incentive) reward calculations need. Resolving it once lets each path be calculated on its // own without duplicating the deposit/pool/reward-state setup. type positionRewardContext struct { deposit *sr.Deposit depositResolver *DepositResolver poolResolver *PoolResolver rewardState *RewardState warmupLen int update positionRewardUpdate } // newPositionRewardContext resolves the deposit, its pool and a fresh RewardState WITHOUT mutating // persisted state. A missing pool is created ephemerally and its persistence is deferred to the // update path. func (s *stakerV1) newPositionRewardContext(param *calculatePositionRewardParam) *positionRewardContext { deposit := param.deposit() poolPath := deposit.TargetPoolPath() pool, poolExisted := param.Pools.Get(poolPath) if !poolExisted { // Read-only: use an ephemeral pool; persistence is deferred to updatePositionReward. pool = sr.NewPool(poolPath, param.CurrentTime) } poolResolver := newPoolResolverWithExit(pool, param.Exit) return &positionRewardContext{ deposit: deposit, depositResolver: NewDepositResolver(deposit), poolResolver: poolResolver, rewardState: poolResolver.RewardStateOf(deposit), warmupLen: len(deposit.Warmups()), update: positionRewardUpdate{ poolPath: poolPath, pool: pool, poolExisted: poolExisted, }, } } // newRewards allocates the empty per-warmup reward slice the compute* methods write into. func (self *positionRewardContext) newRewards() []Reward { rewards := make([]Reward, self.warmupLen) for i := 0; i < self.warmupLen; i++ { rewards[i] = Reward{ External: make(map[string]int64), ExternalPenalty: make(map[string]int64), } } return rewards } // computeInternalRewards writes the GNS emission reward and penalty of each warmup into rewards. // // The per-second emission-rate schedule is resolved up-front (resolveInternalRewardSegments) and fed to // the single internal-reward calculator, so the calculation needs no reward-cache update. func (self *positionRewardContext) computeInternalRewards(param *calculatePositionRewardParam, rewards []Reward) error { lastCollectTime := self.depositResolver.InternalRewardLastCollectTime() // Resolve the per-second reward-rate schedule (pure) and calculate internal rewards from it. internalSegments, err := self.poolResolver.resolveInternalRewardSegments(param.PoolTier, self.update.poolPath, lastCollectTime, param.CurrentTime) if err != nil { return err } calculatedInternalRewards, calculatedInternalPenalties := self.rewardState.calculateInternalReward(internalSegments) for i := 0; i < self.warmupLen; i++ { rewards[i].Internal = calculatedInternalRewards[i] rewards[i].InternalPenalty = calculatedInternalPenalties[i] } self.rewardState.reset() return nil } // discoverExternalIncentives records the incentives created since the deposit's last update on the // update, WITHOUT mutating the deposit. The collect path adds them to the deposit's incentive index. // // ExternalRewardLastCollectTime falls back to StakeTime for ids not yet persisted, so a newly-discovered // incentive yields the same calculation whether or not it is written to the deposit. func (self *positionRewardContext) discoverExternalIncentives(param *calculatePositionRewardParam) { lastExternalIncentiveUpdatedAt := self.depositResolver.LastExternalIncentiveUpdatedAt() if lastExternalIncentiveUpdatedAt >= param.CurrentTime { return } // Discover incentives from this pool's own start-time index. Using the // local resolver keeps calculation read-only even for an ephemeral pool // that has not yet been persisted in param.Pools. newIds := make([]string, 0) self.poolResolver.IncentivesResolver().IterateIncentiveIdsByTime(lastExternalIncentiveUpdatedAt, param.CurrentTime, func(incentiveId string) bool { newIds = append(newIds, incentiveId) return false }) self.update.newExternalIncentiveIds = newIds self.update.advanceExternalIncentiveCursor = true } // externalIncentiveIds returns the deposit's effective incentive-id set: the ids stored on the deposit // plus the ones discoverExternalIncentives found. Call it after discoverExternalIncentives. func (self *positionRewardContext) externalIncentiveIds() []string { seen := make(map[string]bool) incentiveIds := make([]string, 0) self.deposit.IterateExternalIncentiveIds(func(incentiveId string) bool { if !seen[incentiveId] { seen[incentiveId] = true incentiveIds = append(incentiveIds, incentiveId) } return false }) for _, incentiveId := range self.update.newExternalIncentiveIds { if !seen[incentiveId] { seen[incentiveId] = true incentiveIds = append(incentiveIds, incentiveId) } } return incentiveIds } // computeExternalReward writes one incentive's external reward and penalty of each warmup into rewards. // An incentive that does not exist on the pool or has not started yet contributes nothing. func (self *positionRewardContext) computeExternalReward(param *calculatePositionRewardParam, rewards []Reward, incentiveId string) { incentive, ok := self.poolResolver.IncentivesResolver().Get(incentiveId) if !ok { return } incentiveResolver := NewExternalIncentiveResolver(incentive) // Check if incentive is active during this specific collection period if !incentiveResolver.IsStarted(param.CurrentTime) { return } // External incentivized pool. // Calculate reward for each warmup using per-incentive lastCollectTime externalLastCollectTime := self.depositResolver.ExternalRewardLastCollectTime(incentiveId) externalReward, externalPenalty := self.rewardState.calculateExternalReward(externalLastCollectTime, param.CurrentTime, incentive) for i := range externalReward { if externalReward[i] > 0 || externalPenalty[i] > 0 { rewards[i].External[incentiveId] = externalReward[i] rewards[i].ExternalPenalty[incentiveId] = externalPenalty[i] } } self.update.externalIncentiveIds = append(self.update.externalIncentiveIds, incentiveId) self.rewardState.reset() } // computeExternalRewards writes every incentive of the deposit into rewards. func (self *positionRewardContext) computeExternalRewards(param *calculatePositionRewardParam, rewards []Reward) { self.discoverExternalIncentives(param) for _, incentiveId := range self.externalIncentiveIds() { self.computeExternalReward(param, rewards, incentiveId) } } // calculateCollectablePositionReward calculates the aggregated position reward WITHOUT mutating any persisted state. // // It is the shared, read-only entry point used by the Collectable* view getters. // This keeps the calculation identical for every caller and guarantees views never write. func (s *stakerV1) calculateCollectablePositionReward(currentHeight, currentTimestamp int64, positionId uint64) (Reward, error) { param := &calculatePositionRewardParam{ CurrentHeight: currentHeight, CurrentTime: currentTimestamp, Deposits: s.getDeposits(), Pools: s.getPools(), PoolTier: s.getPoolTier(), PositionId: positionId, } // An unstaked position quotes against its exit checkpoint, at its exit time. if checkpoint := s.getUnstakedPositions().get(positionId); checkpoint != nil { param.CurrentTime = checkpoint.ExitTime() param.Deposit = checkpoint.Deposit() param.Exit = checkpoint } rewards, _, err := s.calculatePositionReward(param) if err != nil { return Reward{}, err } return aggregateRewards(rewards), nil } // calculatePositionReward computes a position's per-warmup internal AND external rewards WITHOUT // mutating persisted state. All would-be state changes are returned as a positionRewardUpdate for the // caller to apply (collect only). func (s *stakerV1) calculatePositionReward(param *calculatePositionRewardParam) ([]Reward, positionRewardUpdate, error) { ctx := s.newPositionRewardContext(param) rewards := ctx.newRewards() if err := ctx.computeInternalRewards(param, rewards); err != nil { return nil, positionRewardUpdate{}, err } ctx.computeExternalRewards(param, rewards) return rewards, ctx.update, nil } // calculateExternalPositionRewards computes all of a position's external incentive rewards WITHOUT // mutating persisted state. Used by the combined collect path while GNS emission is halted. func (s *stakerV1) calculateExternalPositionRewards(param *calculatePositionRewardParam) ([]Reward, positionRewardUpdate) { ctx := s.newPositionRewardContext(param) rewards := ctx.newRewards() ctx.computeExternalRewards(param, rewards) return rewards, ctx.update } // calculateInternalPositionReward computes only a position's per-warmup GNS emission rewards, WITHOUT // mutating persisted state. Used by the emission collect path. func (s *stakerV1) calculateInternalPositionReward(param *calculatePositionRewardParam) ([]Reward, positionRewardUpdate, error) { ctx := s.newPositionRewardContext(param) rewards := ctx.newRewards() if err := ctx.computeInternalRewards(param, rewards); err != nil { return nil, positionRewardUpdate{}, err } return rewards, ctx.update, nil } // calculateExternalPositionReward computes a position's per-warmup reward for one external incentive, // WITHOUT mutating persisted state. Used by the external incentive collect path. // // Incentive discovery still runs over every incentive, so the deposit's incentive index stays complete // no matter which incentive the caller asked for. func (s *stakerV1) calculateExternalPositionReward(param *calculatePositionRewardParam, incentiveId string) ([]Reward, positionRewardUpdate) { ctx := s.newPositionRewardContext(param) rewards := ctx.newRewards() ctx.discoverExternalIncentives(param) ctx.computeExternalReward(param, rewards, incentiveId) return rewards, ctx.update } // persistLazyPool persists a pool that calculation created ephemerally because it did not exist yet. func (s *stakerV1) persistLazyPool(param *calculatePositionRewardParam, updateParams positionRewardUpdate) { if !updateParams.poolExisted { param.Pools.set(updateParams.poolPath, updateParams.pool) } } // updateInternalRewardCache advances the reward cache up to CurrentTime (halving boundaries). The // calculation itself no longer needs this, but downstream unclaimable processing (which reads // CurrentReward) and off-chain history rely on the cache, so it is advanced on the emission collect path. func (s *stakerV1) updateInternalRewardCache(param *calculatePositionRewardParam, updateParams positionRewardUpdate) { // Always at the current time, never at param.CurrentTime: the cache and the unclaimable // tracking it drives are pool-global, and a checkpoint collect would rewind them to its exit // timestamp, where the pool may have had no staked liquidity. param.PoolTier.cacheRewardForPool(time.Now().Unix(), param.Pools, updateParams.poolPath) } // applyExternalIncentiveIndex persists the deposit incentive-index updates discovered during calculation. func (s *stakerV1) applyExternalIncentiveIndex(param *calculatePositionRewardParam, updateParams positionRewardUpdate) { if len(updateParams.newExternalIncentiveIds) == 0 && !updateParams.advanceExternalIncentiveCursor { return } deposit := param.deposit() for _, incentiveId := range updateParams.newExternalIncentiveIds { deposit.AddExternalIncentiveId(incentiveId) } if updateParams.advanceExternalIncentiveCursor { deposit.SetLastExternalIncentiveUpdatedAt(param.CurrentTime) } } // updatePositionReward applies the persisted-state changes produced by calculatePositionReward. // It is called ONLY by the collect path; the Collectable* view getters discard the update. func (s *stakerV1) updatePositionReward(param *calculatePositionRewardParam, updateParams positionRewardUpdate) { s.persistLazyPool(param, updateParams) s.updateInternalRewardCache(param, updateParams) s.applyExternalIncentiveIndex(param, updateParams) } // updateInternalPositionReward applies the state changes belonging to the emission collect path only. func (s *stakerV1) updateInternalPositionReward(param *calculatePositionRewardParam, updateParams positionRewardUpdate) { s.persistLazyPool(param, updateParams) s.updateInternalRewardCache(param, updateParams) } // updateExternalPositionReward applies the state changes belonging to the external collect path only. func (s *stakerV1) updateExternalPositionReward(param *calculatePositionRewardParam, updateParams positionRewardUpdate) { s.persistLazyPool(param, updateParams) s.applyExternalIncentiveIndex(param, updateParams) } // internalRewardSegment is a [start, end) span over which the per-second emission reward rate is constant. type internalRewardSegment struct { start int64 end int64 rewardPerSecond int64 } // resolveInternalRewardSegments builds the per-second reward-rate schedule over [startTime, endTime] // WITHOUT mutating state. // // Persisted reward-cache entries cover the historical portion: they record tier/count changes and any // halvings already materialized by past collects, so no un-materialized halving exists strictly between // two persisted entries. The tail beyond the last persisted entry is split by halvings using the current // tier ratio/count, which are necessarily constant there (a change would have written a cache entry). The // tail rate is recomputed with the same calculatePoolReward arithmetic the cache writer uses, so a // schedule resolved from a fully materialized cache and one resolved from the emission halvings are // identical. func (self *PoolResolver) resolveInternalRewardSegments(poolTier *PoolTier, poolPath string, startTime, endTime int64) ([]internalRewardSegment, error) { segments := make([]internalRewardSegment, 0) if startTime >= endTime { return segments, nil } currentReward := self.CurrentReward(startTime) cursor := startTime self.RewardCache().Iterate(startTime, endTime, func(key int64, value any) bool { reward, ok := value.(int64) if !ok { panic(ufmt.Sprintf("failed to cast value to int64: %T", value)) } segments = append(segments, internalRewardSegment{start: cursor, end: key, rewardPerSecond: currentReward}) cursor = key currentReward = reward return false }) if cursor < endTime { var err error segments, err = appendInternalRewardTailSegments(segments, poolTier, poolPath, cursor, endTime, currentReward, self.exit) if err != nil { return nil, err } } return segments, nil } // appendInternalRewardTailSegments appends the schedule for the tail [startTime, endTime], where no // persisted cache entry exists beyond startTime. Over this span tier/count are constant, so the rate // changes only at halving boundaries. func appendInternalRewardTailSegments(segments []internalRewardSegment, poolTier *PoolTier, poolPath string, startTime, endTime, baseReward int64, exit *sr.UnstakedPosition) ([]internalRewardSegment, error) { // A checkpoint reads the tier context it exited under: this window closed at the exit, and // the live lookup is skipped so a checkpoint collect never depends on live tier state. var tier, ratio, count uint64 if exit != nil { tier, ratio, count = exit.Tier(), exit.TierRatio(), exit.TierCount() } else { var err error tier, ratio, count, err = poolTier.tierContextOf(poolPath) if err != nil { return nil, err } } if tier == 0 || tier >= AllTierCount { // Not currently tiered: the rate cannot increase; the base is 0 after de-tier. return append(segments, internalRewardSegment{start: startTime, end: endTime, rewardPerSecond: baseReward}), nil } tierRatioInt64 := int64(ratio) tierCount := int64(count) halvingTimestamps, halvingEmissions, err := poolTier.getHalvingBlocksInRange(startTime, endTime) if err != nil { return nil, err } segStart := startTime rate := baseReward for i, hv := range halvingTimestamps { if hv <= segStart { // Halving effective at/before the segment start: only switch the rate. rate, err = calculatePoolReward(halvingEmissions[i], tierRatioInt64, tierCount) if err != nil { return nil, err } continue } if hv >= endTime { break } segments = append(segments, internalRewardSegment{start: segStart, end: hv, rewardPerSecond: rate}) rate, err = calculatePoolReward(halvingEmissions[i], tierRatioInt64, tierCount) if err != nil { return nil, err } segStart = hv } return append(segments, internalRewardSegment{start: segStart, end: endTime, rewardPerSecond: rate}), nil } // calculates internal unclaimable reward for the pool func (s *stakerV1) processUnClaimableReward(poolPath string, endTimestamp int64) int64 { pool, ok := s.getPools().Get(poolPath) if !ok { return 0 } poolResolver := NewPoolResolver(pool) return poolResolver.processUnclaimableReward(endTimestamp) } // update deposit's incentive list with new incentives created since last update func (s *stakerV1) getExternalIncentiveIdsBy(poolPath string, startTime, endTime int64) []string { currentIncentiveIds := make([]string, 0) pool, ok := s.getPools().Get(poolPath) if !ok { return currentIncentiveIds } poolResolver := NewPoolResolver(pool) // Look up the pool's own start-time index instead of a global // creation-time index. The index is scoped to this pool's incentives, so // discovery cost is bounded by the number of incentives for this pool // within the queried range, and no longer grows with the total number of // incentives system-wide. poolResolver.IncentivesResolver().IterateIncentiveIdsByTime(startTime, endTime, func(incentiveId string) bool { currentIncentiveIds = append(currentIncentiveIds, incentiveId) return false }) return currentIncentiveIds } // getInitialCollectTime determines the initial collection time for an incentive // by taking the maximum of the deposit's stake time and the incentive's start time. // This ensures rewards are only calculated from when both conditions are met: // - The position must be staked (deposit.stakeTime) // - The incentive must be active (incentive.startTimestamp) // // This function is used for lazy initialization when a position collects // from an incentive for the first time, avoiding the need to iterate through // all deposits when a new incentive is created. func getInitialCollectTime(deposit *sr.Deposit, incentive *sr.ExternalIncentive) int64 { if deposit.StakeTime() > incentive.StartTimestamp() { return deposit.StakeTime() } return incentive.StartTimestamp() }
  8. #8consts.gno
  9. #9package staker const ( GNS_TOKEN_KEY string = "gno.land/r/gnoswap/gns.GNS" WUGNOT_TOKEN_KEY string = "gno.land/r/gnoland/wugnot.wugnot" )
  10. #10doc.gno
  11. #11// package staker manages liquidity mining rewards for GnoSwap positions. // // The staker distributes GNS emissions and external incentives to liquidity // providers based on their position size, price range, and staking duration. // It supports both internal GNS rewards and external token incentives. // // Reward calculations combine elapsed-time rates with tick/range accumulation, and // collection flows are integrated into the staking lifecycle. package staker
  12. #12emits.gno
  13. #13package staker import ( "chain" "chain/runtime" "gno.land/p/gnoswap/utils/v1" ) func emitSetPoolTier( prevAddr string, prevRealm string, poolPath string, tier uint64, rewardPerSecond int64, tierRewards map[uint64]int64, counts [AllTierCount]uint64, currentTime int64, ) { chain.Emit( "SetPoolTier", "prevAddr", prevAddr, "prevRealm", prevRealm, "poolPath", poolPath, "tier", utils.FormatUint(tier), "rewardPerSecond", utils.FormatInt(rewardPerSecond), "tier1RewardPerSecond", utils.FormatInt(tierRewards[Tier1]), "tier2RewardPerSecond", utils.FormatInt(tierRewards[Tier2]), "tier3RewardPerSecond", utils.FormatInt(tierRewards[Tier3]), "tier1Count", utils.FormatUint(counts[Tier1]), "tier2Count", utils.FormatUint(counts[Tier2]), "tier3Count", utils.FormatUint(counts[Tier3]), "currentTime", utils.FormatInt(currentTime), "currentHeight", utils.FormatInt(runtime.ChainHeight()), ) }
  14. #14errors.gno
  15. #15package staker import ( ufmt "gno.land/p/nt/ufmt/v0" ) const ( errNoPermission = "[GNOSWAP-STAKER-001] caller has no permission" errPoolNotFound = "[GNOSWAP-STAKER-002] pool not found" errNotAvailableUpdateCollectTime = "[GNOSWAP-STAKER-003] not available to update collect time" errInvalidInput = "[GNOSWAP-STAKER-004] invalid input data" errInvalidUnstakingFee = "[GNOSWAP-STAKER-005] invalid unstaking fee" errAlreadyStaked = "[GNOSWAP-STAKER-006] already staked position" errNonIncentivizedPool = "[GNOSWAP-STAKER-007] pool is not incentivized" errCannotEndIncentive = "[GNOSWAP-STAKER-008] cannot end incentive" errInvalidIncentiveStartTime = "[GNOSWAP-STAKER-009] invalid incentive start time" errDefaultExternalToken = "[GNOSWAP-STAKER-010] cannot delete default external token" errInvalidPoolPath = "[GNOSWAP-STAKER-011] invalid pool path" errInvalidPoolTier = "[GNOSWAP-STAKER-012] invalid pool tier" errDataNotFound = "[GNOSWAP-STAKER-013] requested data not found" errCalculationError = "[GNOSWAP-STAKER-014] unexpected calculation error" errZeroLiquidity = "[GNOSWAP-STAKER-015] zero liquidity" errInvalidIncentiveDuration = "[GNOSWAP-STAKER-016] invalid incentive duration" errNotAllowedForExternalReward = "[GNOSWAP-STAKER-017] not allowed for external reward" errIncentiveAlreadyExists = "[GNOSWAP-STAKER-018] incentive already exists" errOverflow = "[GNOSWAP-STAKER-019] overflow" errAddExistingToken = "[GNOSWAP-STAKER-020] cannot add existing token" errInvalidAddress = "[GNOSWAP-STAKER-021] invalid address" errIsNotEndedIncentive = "[GNOSWAP-STAKER-022] incentive is not ended yet" errCannotCancelIncentive = "[GNOSWAP-STAKER-023] cannot cancel incentive" errUncollectedPosition = "[GNOSWAP-STAKER-024] position has uncollected rewards" errDeniedRewardToken = "[GNOSWAP-STAKER-025] denied reward token" errCannotDenyDefaultToken = "[GNOSWAP-STAKER-026] cannot deny default token" errInsufficientRewardTokenBalance = "[GNOSWAP-STAKER-027] insufficient reward token balance" ) func makeErrorWithDetails(message string, details string) error { return ufmt.Errorf("%s || %s", message, details) }
  16. #16event_info.gno
  17. #17package staker import ( i256 "gno.land/p/gnoswap/int256/v1" u256 "gno.land/p/gnoswap/uint256/v1" "gno.land/p/gnoswap/utils/v1" ) type tickCrossEventInfo struct { tickID int32 stakedLiquidityGross *u256.Uint stakedLiquidityDelta *i256.Int outsideAccumulation *u256.Uint } // ToString serializes tick-cross information as a compact JSON object for event emission. // - t: Tick ID // - slg: Staked Liquidity Gross // - sld: Staked Liquidity Delta // - oa: Outside Accumulation // // Returns: // - json: JSON object string containing the tick ID and the three encoded liquidity/accumulation values. func (t *tickCrossEventInfo) ToString() string { return "{\"t\":" + utils.FormatInt(t.tickID) + ",\"slg\":\"" + t.stakedLiquidityGross.ToString() + "\"" + ",\"sld\":\"" + t.stakedLiquidityDelta.ToString() + "\"" + ",\"oa\":\"" + t.outsideAccumulation.ToString() + "\"}" } // NewTickCrossEventInfo captures the values emitted when a swap crosses a tick. // // Parameters: // - tickID: Tick index crossed during the swap. // - stakedLiquidityGross: Total staked liquidity at the crossed tick. // - stakedLiquidityDelta: Net staked liquidity change applied at the crossed tick. // - outsideAccumulation: Reward-ratio accumulation outside the crossed tick. // // Returns: // - info: Event payload containing the supplied tick-cross values. func NewTickCrossEventInfo(tickID int32, stakedLiquidityGross *u256.Uint, stakedLiquidityDelta *i256.Int, outsideAccumulation *u256.Uint) *tickCrossEventInfo { return &tickCrossEventInfo{ tickID: tickID, stakedLiquidityGross: stakedLiquidityGross, stakedLiquidityDelta: stakedLiquidityDelta, outsideAccumulation: outsideAccumulation, } }
  18. #18external_deposit_fee.gno
  19. #19package staker import ( "chain" "strconv" "strings" "gno.land/p/gnoswap/utils/v1" ufmt "gno.land/p/nt/ufmt/v0" "gno.land/r/gnoswap/access/v1" "gno.land/r/gnoswap/halt/v1" ) // GetDepositGnsAmount returns the current GNS deposit amount required for each external incentive. // // Returns: // - amount: GNS deposit amount required per external incentive, in token smallest units. func (s *stakerV1) GetDepositGnsAmount() int64 { return s.store.GetDepositGnsAmount() } // GetMinimumRewardAmount returns the default minimum reward amount required for external incentives. // // Returns: // - amount: Default minimum external-incentive reward amount in token smallest units. func (s *stakerV1) GetMinimumRewardAmount() int64 { return s.store.GetMinimumRewardAmount() } // GetMinimumRewardAmountForToken returns the minimum reward amount for a specific token. // // Parameters: // - tokenPath: Token path whose configured minimum reward amount should be read. // // Returns: // - amount: Token-specific minimum reward amount when configured, otherwise the default minimum amount. func (s *stakerV1) GetMinimumRewardAmountForToken(tokenPath string) int64 { amount, found := s.store.GetTokenSpecificMinimumRewards()[tokenPath] if found { return amount } // Fallback to default if not found return s.GetMinimumRewardAmount() } // GetSpecificTokenMinimumRewardAmount returns the explicitly set minimum reward amount for a token. // // Parameters: // - tokenPath: Token path whose explicit minimum reward override should be read. // // Returns: // - amount: Explicit minimum reward amount in token smallest units, or 0 when no override is configured. // - found: True when tokenPath has an explicit override; false when the default would be used. func (s *stakerV1) GetSpecificTokenMinimumRewardAmount(tokenPath string) (int64, bool) { amount, found := s.store.GetTokenSpecificMinimumRewards()[tokenPath] if !found { return 0, false } return amount, true } // SetDepositGnsAmount sets the GNS deposit amount required for creating external incentives. // Only admin or governance can call this function. // // Parameters: // - _: Noncrossing implementation-call discriminator; pass 0. // - rlm: Current realm context forwarded unchanged by the staker proxy. // - amount: Non-negative GNS deposit amount in token smallest units to require per external incentive. func (s *stakerV1) SetDepositGnsAmount(_ int, rlm realm, amount int64) { access.AssertIsRlmCurrent(0, rlm) halt.AssertIsNotHaltedStaker() previousRealm := rlm.Previous() caller := previousRealm.Address() access.AssertIsAdminOrGovernance(caller) assertIsValidAmount(amount) prevDepositGnsAmount := s.store.GetDepositGnsAmount() s.setDepositGnsAmount(0, rlm, amount) chain.Emit( "SetDepositGnsAmount", "prevAddr", caller.String(), "prevRealm", previousRealm.PkgPath(), "prevAmount", utils.FormatInt(prevDepositGnsAmount), "newAmount", utils.FormatInt(amount), ) } // SetMinimumRewardAmount sets the default minimum reward amount for external incentives. // Only admin or governance can call this function. // // Parameters: // - _: Noncrossing implementation-call discriminator; pass 0. // - rlm: Current realm context forwarded unchanged by the staker proxy. // - amount: Non-negative default minimum reward amount in token smallest units for external incentives. func (s *stakerV1) SetMinimumRewardAmount(_ int, rlm realm, amount int64) { access.AssertIsRlmCurrent(0, rlm) halt.AssertIsNotHaltedStaker() previousRealm := rlm.Previous() caller := previousRealm.Address() access.AssertIsAdminOrGovernance(caller) assertIsValidAmount(amount) prevMinimumRewardAmount := s.getMinimumRewardAmount() s.setMinimumRewardAmount(0, rlm, amount) chain.Emit( "SetMinimumRewardAmount", "prevAddr", caller.String(), "prevRealm", previousRealm.PkgPath(), "prevAmount", utils.FormatInt(prevMinimumRewardAmount), "newAmount", utils.FormatInt(amount), ) } // SetTokenMinimumRewardAmount sets the minimum reward amount for a specific token. // Only admin or governance can call this function. // // Parameters: // - _: Noncrossing implementation-call discriminator; pass 0. // - rlm: Current realm context forwarded unchanged by the staker proxy. // - paramsStr: Reward override specification in the form "tokenPath:amount"; amount 0 removes an existing token-specific override, while a nonzero amount stores it. func (s *stakerV1) SetTokenMinimumRewardAmount(_ int, rlm realm, paramsStr string) { access.AssertIsRlmCurrent(0, rlm) halt.AssertIsNotHaltedStaker() previousRealm := rlm.Previous() caller := previousRealm.Address() access.AssertIsAdminOrGovernance(caller) assertIsValidRewardAmountFormat(paramsStr) // Parse the paramsStr parts := strings.SplitN(paramsStr, ":", 2) tokenPath := parts[0] amountStr := parts[1] amount64, err := strconv.ParseInt(amountStr, 10, 64) if err != nil { panic(makeErrorWithDetails( errInvalidInput, ufmt.Sprintf("invalid amount format in params '%s': %v", paramsStr, err), )) } prevAmount, found := s.store.GetTokenSpecificMinimumRewards()[tokenPath] // If amount is 0, remove the entry; otherwise, set it. if amount64 == 0 { // Only attempt removal if an entry actually existed if found { if err := s.store.RemoveTokenSpecificMinimumRewardItem(0, rlm, tokenPath); err != nil { panic(err) } } } else { if err := s.store.SetTokenSpecificMinimumRewardItem(0, rlm, tokenPath, amount64); err != nil { panic(err) } } chain.Emit( "SetTokenMinimumRewardAmount", "prevAddr", caller.String(), "prevRealm", previousRealm.PkgPath(), "tokenPath", tokenPath, "prevAmountFound", utils.FormatBool(found), "prevAmount", utils.FormatInt(prevAmount), // Will be 0 if !found "newAmount", utils.FormatInt(amount64), ) } // setDepositGnsAmount internally updates the deposit GNS amount. func (s *stakerV1) setDepositGnsAmount(_ int, rlm realm, amount int64) { s.store.SetDepositGnsAmount(0, rlm, amount) } // setMinimumRewardAmount internally updates the minimum reward amount. func (s *stakerV1) setMinimumRewardAmount(_ int, rlm realm, amount int64) { s.store.SetMinimumRewardAmount(0, rlm, amount) } // getMinimumRewardAmount internally retrieves the minimum reward amount. func (s *stakerV1) getMinimumRewardAmount() int64 { return s.store.GetMinimumRewardAmount() }
  20. #20external_incentive.gno
  21. #21package staker import ( "chain" "chain/runtime" "time" "gno.land/p/gnoswap/gnsmath/v1" prbac "gno.land/p/gnoswap/rbac/v1" u256 "gno.land/p/gnoswap/uint256/v1" "gno.land/p/gnoswap/utils/v1" ufmt "gno.land/p/nt/ufmt/v0" "gno.land/r/gnoswap/access/v1" "gno.land/r/gnoswap/common" en "gno.land/r/gnoswap/emission" "gno.land/r/gnoswap/halt/v1" sr "gno.land/r/gnoswap/staker" ) // CreateExternalIncentive creates an external incentive program for a pool. // // Parameters: // - _: Noncrossing implementation-call discriminator; pass 0. // - rlm: Current realm context forwarded unchanged by the staker proxy. // - targetPoolPath: Pool path to incentivize. // - rewardToken: Registered reward-token path. // - rewardAmount: Total reward amount deposited, in reward-token units. // - startTimestamp: Incentive start time as Unix seconds. // - endTimestamp: Incentive end time as Unix seconds; it must follow startTimestamp by a valid duration. // // Any caller may create an incentive after satisfying token, duration, start-time, reward-minimum, // and GNS-deposit checks. func (s *stakerV1) CreateExternalIncentive( _ int, rlm realm, targetPoolPath string, rewardToken string, // token path should be registered rewardAmount int64, startTimestamp int64, endTimestamp int64, ) { access.AssertIsRlmCurrent(0, rlm) halt.AssertIsNotHaltedStaker() prevRealm := rlm.Previous() caller := prevRealm.Address() currentTime := time.Now().Unix() assertIsPoolExists(s, targetPoolPath) assertIsGreaterThanMinimumRewardAmount(s, rewardToken, rewardAmount) assertIsAllowedForExternalReward(s, targetPoolPath, rewardToken) assertIsExternalRewardTokenAvailable(s, targetPoolPath, rewardToken, currentTime) assertIsValidIncentiveStartTime(startTimestamp) assertIsValidIncentiveEndTime(endTimestamp) assertIsValidIncentiveDuration(gnsmath.SafeSubInt64(endTimestamp, startTimestamp)) // assert that the user has sent the correct amount of native coin common.AssertIsNotHandleNativeCoin() en.MintAndDistributeGns(cross(rlm)) stakerAddr := access.MustGetAddress(prbac.ROLE_STAKER.String()) // transfer reward token from user to staker common.SafeGRC20TransferFrom(0, rlm, rewardToken, caller, stakerAddr, rewardAmount) depositGnsAmount := s.store.GetDepositGnsAmount() // deposit gns amount common.SafeGRC20TransferFrom(0, rlm, GNS_TOKEN_KEY, caller, stakerAddr, depositGnsAmount) currentHeight := runtime.ChainHeight() incentiveId := s.store.NextIncentiveID(caller, currentTime) pool := s.getPools().GetPoolOrNil(targetPoolPath) if pool == nil { pool = sr.NewPool(targetPoolPath, currentTime) s.getPools().set(targetPoolPath, pool) } incentive := sr.NewExternalIncentive( incentiveId, targetPoolPath, rewardToken, rewardAmount, startTimestamp, endTimestamp, caller, depositGnsAmount, currentHeight, currentTime, ) externalIncentives := s.store.GetExternalIncentives() if externalIncentives.Has(incentiveId) { panic(makeErrorWithDetails( errIncentiveAlreadyExists, ufmt.Sprintf("incentiveId(%s)", incentiveId), )) } // store external incentive information for each incentiveId externalIncentives.Set(incentiveId, incentive) poolResolver := NewPoolResolver(pool) poolResolver.IncentivesResolver().create(incentive) chain.Emit( "CreateExternalIncentive", "prevAddr", caller.String(), "prevRealm", prevRealm.PkgPath(), "incentiveId", incentiveId, "targetPoolPath", targetPoolPath, "rewardToken", rewardToken, "rewardAmount", utils.FormatInt(rewardAmount), "startTimestamp", utils.FormatInt(startTimestamp), "endTimestamp", utils.FormatInt(endTimestamp), "depositGnsAmount", utils.FormatInt(depositGnsAmount), "currentHeight", utils.FormatInt(currentHeight), "currentTime", utils.FormatInt(currentTime), ) } // EndExternalIncentive ends an external incentive after its end timestamp and finalizes the // refundable unclaimable/remainder amount. // // The reward-token refund and GNS deposit are sent to the caller-supplied refundAddress. Rewards // still owed by live positions remain claimable, and the incentive record is retained for that // accounting. Accumulated warmup penalties are collected separately with // CollectExternalIncentivePenalty after this function succeeds. // // Parameters: // - _: Noncrossing implementation-call discriminator; pass 0. // - rlm: Current realm context forwarded unchanged by the staker proxy. // - targetPoolPath: Pool containing the incentive to end. // - incentiveId: Unique identifier of the incentive to finalize. // - refundAddress: Address receiving the refundable reward amount and GNS deposit. // // Process: // 1. Validates that the incentive end time has been reached and no exit checkpoint still owes it. // 2. Calculates unclaimable/remainder rewards. // 3. Marks the incentive refunded and retains its record. // 4. Transfers the refundable rewards and deposited GNS to refundAddress. // // Only callable by the incentive creator or admin. func (s *stakerV1) EndExternalIncentive(_ int, rlm realm, targetPoolPath, incentiveId string, refundAddress address) { access.AssertIsRlmCurrent(0, rlm) halt.AssertIsNotHaltedWithdraw() // checks pool registry assertIsPoolExists(s, targetPoolPath) assertIsValidAddress(refundAddress) assertHasNoUncollectedIncentive(s, incentiveId) // checks if the pool has been incentivized pool, ok := s.getPools().Get(targetPoolPath) if !ok { panic(makeErrorWithDetails( errDataNotFound, ufmt.Sprintf("targetPoolPath(%s) not found", targetPoolPath), )) } poolResolver := NewPoolResolver(pool) incentivesResolver := poolResolver.IncentivesResolver() // Get incentive to check if GNS already refunded incentiveResolver, exists := incentivesResolver.GetIncentiveResolver(incentiveId) if !exists { panic(makeErrorWithDetails( errCannotEndIncentive, ufmt.Sprintf("cannot end non existent incentive(%s)", incentiveId), )) } // Check if incentive has already been refunded if incentiveResolver.Refunded() { panic(makeErrorWithDetails( errCannotEndIncentive, ufmt.Sprintf("incentive(%s) has already been refunded", incentiveId), )) } caller := rlm.Previous().Address() // Process ending incentive, refund, err := s.endExternalIncentive(poolResolver, incentiveResolver, caller, time.Now().Unix()) if err != nil { panic(err) } stakerAddr := access.MustGetAddress(prbac.ROLE_STAKER.String()) poolLeftExternalRewardAmount := common.BalanceOf(incentiveResolver.RewardToken(), stakerAddr) if poolLeftExternalRewardAmount < refund { previousRealm := rlm.Previous() chain.Emit( "EndExternalIncentiveShortfall", "prevAddr", previousRealm.Address().String(), "prevRealm", previousRealm.PkgPath(), "incentiveId", incentiveId, "targetPoolPath", targetPoolPath, "refundee", refundAddress.String(), "refundToken", incentiveResolver.RewardToken(), "expectedRefundAmount", utils.FormatInt(refund), "actualRefundAmount", utils.FormatInt(poolLeftExternalRewardAmount), "creator", incentiveResolver.Creator().String(), ) refund = poolLeftExternalRewardAmount } // Mark incentive as refunded and update // After this update, attempts to re-claim GNS or rewards that were deposited // through the `endExternalIncentive` function will be blocked. incentiveResolver.SetRefunded(true) incentiveResolver.SetRewardAmount(gnsmath.SafeSubInt64(incentiveResolver.RewardAmount(), refund)) incentiveResolver.addDistributedRewardAmount(refund) incentivesResolver.update(incentive) // refund reward token to refundee common.SafeGRC20Transfer(0, rlm, incentiveResolver.RewardToken(), refundAddress, refund) // Transfer GNS deposit back to refundee common.SafeGRC20Transfer(0, rlm, GNS_TOKEN_KEY, refundAddress, incentiveResolver.DepositGnsAmount()) previousRealm := rlm.Previous() chain.Emit( "EndExternalIncentive", "prevAddr", previousRealm.Address().String(), "prevRealm", previousRealm.PkgPath(), "incentiveId", incentiveId, "targetPoolPath", targetPoolPath, "refundee", refundAddress.String(), "refundToken", incentiveResolver.RewardToken(), "refundAmount", utils.FormatInt(refund), "refundGnsAmount", utils.FormatInt(incentiveResolver.DepositGnsAmount()), "externalIncentiveEndBy", previousRealm.Address().String(), "creator", incentiveResolver.Creator().String(), ) } // endExternalIncentive processes the end of an external incentive program. func (s *stakerV1) endExternalIncentive(resolver *PoolResolver, incentiveResolver *ExternalIncentiveResolver, caller address, currentTime int64) (*sr.ExternalIncentive, int64, error) { if currentTime < incentiveResolver.EndTimestamp() { return nil, 0, makeErrorWithDetails( errCannotEndIncentive, ufmt.Sprintf("cannot end incentive before endTime(%d), current(%d)", incentiveResolver.EndTimestamp(), currentTime), ) } // only creator or admin can end incentive if !access.IsAuthorized(prbac.ROLE_ADMIN.String(), caller) && caller != incentiveResolver.Creator() { adminAddr := access.MustGetAddress(prbac.ROLE_ADMIN.String()) return nil, 0, makeErrorWithDetails( errNoPermission, ufmt.Sprintf( "only creator(%s) or admin(%s) can end incentive, but called from %s", incentiveResolver.Creator(), adminAddr.String(), caller, ), ) } // refund = unclaimableReward + remainder. Accumulated warmup penalties are tracked separately // and collected through CollectExternalIncentivePenalty after the incentive is ended. incentivesResolver := resolver.IncentivesResolver() unclaimableReward := incentivesResolver.calculateUnclaimableReward(incentiveResolver.IncentiveId()) duration := gnsmath.SafeSubInt64(incentiveResolver.EndTimestamp(), incentiveResolver.StartTimestamp()) distributableU256 := u256.MulDiv( incentiveResolver.RewardPerSecondX128(), u256.NewUintFromInt64(duration), q128, ) distributable := gnsmath.SafeConvertToInt64(distributableU256) remainder := gnsmath.SafeSubInt64(incentiveResolver.TotalRewardAmount(), distributable) refund := gnsmath.SafeAddInt64(unclaimableReward, remainder) maxRefund := incentiveResolver.RewardAmount() if refund > maxRefund { refund = maxRefund } if refund < 0 { return nil, 0, makeErrorWithDetails( errCalculationError, ufmt.Sprintf("refund should never be negative: Got %d", refund), ) } return incentiveResolver.ExternalIncentive, refund, nil } // CancelExternalIncentive cancels an external incentive before it starts and // removes it entirely. // // EndExternalIncentive is the counterpart for an incentive whose end timestamp // has passed: it keeps the record so the penalty accounting and the deposits // that already collected from it stay resolvable. Cancelling is only allowed // strictly before startTimestamp, where nothing has accrued yet - no deposit // references the incentive (both StakeToken and the lazy reward discovery only // look at incentives whose start timestamp is already in the past), so the // record can be dropped from the incentive tree and the start-time index // instead of being marked refunded. // // Parameters: // - _: Noncrossing implementation-call discriminator; pass 0. // - rlm: Current realm context forwarded unchanged by the staker proxy. // - targetPoolPath: Pool path for which the incentive was created. // - incentiveId: Unique identifier of the incentive to cancel. // // Process: // 1. Validates the incentive exists and has not started. // 2. Removes it from the incentive tree and the start-time index. // 3. Refunds the available reward-token balance (capped by the staker balance) and the GNS deposit to the creator. // // Only callable by admin, governance, or the incentive creator. The permission // check runs after the incentive is resolved because the creator identity comes // from the stored record; every step before it is read-only, so no unauthorized // side effect can occur ahead of the check. func (s *stakerV1) CancelExternalIncentive(_ int, rlm realm, targetPoolPath, incentiveId string) { access.AssertIsRlmCurrent(0, rlm) halt.AssertIsNotHaltedWithdraw() assertIsPoolExists(s, targetPoolPath) prevRealm := rlm.Previous() caller := prevRealm.Address() pool, ok := s.getPools().Get(targetPoolPath) if !ok { panic(makeErrorWithDetails( errDataNotFound, ufmt.Sprintf("targetPoolPath(%s) not found", targetPoolPath), )) } poolResolver := NewPoolResolver(pool) incentivesResolver := poolResolver.IncentivesResolver() incentiveResolver, exists := incentivesResolver.GetIncentiveResolver(incentiveId) if !exists { panic(makeErrorWithDetails( errCannotCancelIncentive, ufmt.Sprintf("cannot cancel non existent incentive(%s)", incentiveId), )) } // Admin, governance, or the incentive creator may cancel it. assertIsAdminGovernanceOrCreator(caller, incentiveResolver.Creator()) currentTime := time.Now().Unix() assertIsNotStartedIncentive(incentiveResolver, currentTime) rewardToken := incentiveResolver.RewardToken() depositGnsAmount := incentiveResolver.DepositGnsAmount() refundAmount := incentiveResolver.RewardAmount() // The reward tokens and the GNS deposit are always returned to the incentive // creator - the address that funded them in CreateExternalIncentive - so the // refund destination is never caller-controlled. creator := incentiveResolver.Creator() // Cap the refund by the balance actually held, mirroring // EndExternalIncentive: a short balance must not abort the cancellation and // strand the GNS deposit as well. stakerAddr := access.MustGetAddress(prbac.ROLE_STAKER.String()) rewardBalance := common.BalanceOf(rewardToken, stakerAddr) if rewardBalance < refundAmount { chain.Emit( "CancelExternalIncentiveShortfall", "prevAddr", caller.String(), "prevRealm", prevRealm.PkgPath(), "incentiveId", incentiveId, "targetPoolPath", targetPoolPath, "refundToken", rewardToken, "expectedRefundAmount", utils.FormatInt(refundAmount), "actualRefundAmount", utils.FormatInt(rewardBalance), "refundee", creator.String(), "creator", creator.String(), ) refundAmount = rewardBalance } // Effects before interactions: once removed, no stake, collect or discovery // path can resolve the incentive, so the refund cannot be replayed. incentivesResolver.remove(incentiveResolver.ExternalIncentive) s.getExternalIncentives().remove(incentiveId) if refundAmount > 0 { // transfer reward token back to creator common.SafeGRC20Transfer(0, rlm, rewardToken, creator, refundAmount) } if depositGnsAmount > 0 { // transfer GNS deposit back to creator common.SafeGRC20Transfer(0, rlm, GNS_TOKEN_KEY, creator, depositGnsAmount) } chain.Emit( "CancelExternalIncentive", "prevAddr", caller.String(), "prevRealm", prevRealm.PkgPath(), "incentiveId", incentiveId, "targetPoolPath", targetPoolPath, "refundToken", rewardToken, "refundAmount", utils.FormatInt(refundAmount), "refundGnsAmount", utils.FormatInt(depositGnsAmount), "startTimestamp", utils.FormatInt(incentiveResolver.StartTimestamp()), "endTimestamp", utils.FormatInt(incentiveResolver.EndTimestamp()), "externalIncentiveCancelBy", caller.String(), "creator", creator.String(), "currentTime", utils.FormatInt(currentTime), ) } // CollectExternalIncentivePenalty collects accumulated warmup penalties for a specific ended // external incentive. The incentive must first be finalized with EndExternalIncentive. // Penalties are accumulated during CollectReward and stored in the incentive. // This function transfers the accumulated penalty to the specified refund address. // // Parameters: // - _: Noncrossing implementation-call discriminator; pass 0. // - rlm: Current realm context forwarded unchanged by the staker proxy. // - targetPoolPath: Pool containing the ended incentive. // - incentiveId: Unique identifier of the ended incentive whose penalty is collected. // - refundAddress: Address receiving the transferred penalty amount. // // Returns: // - penaltyAmount: Accumulated warmup penalty transferred, capped by the staker's available reward-token balance. // // Only callable by the incentive creator or admin. func (s *stakerV1) CollectExternalIncentivePenalty( _ int, rlm realm, targetPoolPath string, incentiveId string, refundAddress address, ) int64 { access.AssertIsRlmCurrent(0, rlm) halt.AssertIsNotHaltedWithdraw() assertIsPoolExists(s, targetPoolPath) assertIsValidAddress(refundAddress) pool, ok := s.getPools().Get(targetPoolPath) if !ok { panic(makeErrorWithDetails( errDataNotFound, ufmt.Sprintf("targetPoolPath(%s) not found", targetPoolPath), )) } poolResolver := NewPoolResolver(pool) incentivesResolver := poolResolver.IncentivesResolver() incentiveResolver, exists := incentivesResolver.GetIncentiveResolver(incentiveId) if !exists { panic(makeErrorWithDetails( errDataNotFound, ufmt.Sprintf("incentive(%s) not found", incentiveId), )) } if !incentiveResolver.Refunded() { panic(makeErrorWithDetails( errIsNotEndedIncentive, ufmt.Sprintf("incentive(%s) must be ended first (call EndExternalIncentive)", incentiveId), )) } caller := rlm.Previous().Address() if !access.IsAuthorized(prbac.ROLE_ADMIN.String(), caller) && caller != incentiveResolver.Creator() { adminAddr := access.MustGetAddress(prbac.ROLE_ADMIN.String()) panic(makeErrorWithDetails( errNoPermission, ufmt.Sprintf("only creator(%s) or admin(%s) can collect penalty, but called from %s", incentiveResolver.Creator(), adminAddr.String(), caller), )) } penaltyAmount := incentiveResolver.AccumulatedPenaltyAmount() if penaltyAmount == 0 { return 0 } // Cap by actual staker balance stakerAddr := access.MustGetAddress(prbac.ROLE_STAKER.String()) balance := common.BalanceOf(incentiveResolver.RewardToken(), stakerAddr) if balance < penaltyAmount { previousRealm := rlm.Previous() chain.Emit( "CollectExternalIncentivePenaltyShortfall", "prevAddr", previousRealm.Address().String(), "prevRealm", previousRealm.PkgPath(), "targetPoolPath", targetPoolPath, "incentiveId", incentiveId, "refundAddress", refundAddress.String(), "refundToken", incentiveResolver.RewardToken(), "expectedPenaltyAmount", utils.FormatInt(penaltyAmount), "actualPenaltyAmount", utils.FormatInt(balance), "creator", incentiveResolver.Creator().String(), ) penaltyAmount = balance } // Reset accumulated penalty incentiveResolver.SetAccumulatedPenaltyAmount(gnsmath.SafeSubInt64(incentiveResolver.AccumulatedPenaltyAmount(), penaltyAmount)) incentivesResolver.update(incentiveResolver.ExternalIncentive) // Transfer penalty to refund address common.SafeGRC20Transfer(0, rlm, incentiveResolver.RewardToken(), refundAddress, penaltyAmount) previousRealm := rlm.Previous() chain.Emit( "CollectExternalIncentivePenalty", "prevAddr", previousRealm.Address().String(), "prevRealm", previousRealm.PkgPath(), "targetPoolPath", targetPoolPath, "incentiveId", incentiveId, "refundAddress", refundAddress.String(), "penaltyAmount", utils.FormatInt(penaltyAmount), ) return penaltyAmount }
  22. #22external_token_list.gno
  23. #23package staker import ( "chain" ufmt "gno.land/p/nt/ufmt/v0" "gno.land/r/gnoswap/access/v1" "gno.land/r/gnoswap/common" "gno.land/r/gnoswap/halt/v1" sr "gno.land/r/gnoswap/staker" ) // AddToken adds a registered token key to the list of allowed tokens. // Only admin or governance can add a token. // // Parameters: // - _: Leading realm-call discriminator; callers pass 0. // - rlm: Current staker realm context, validated before storage is changed. // - tokenPath: Registered token path to add to the allowed-token list. // // Panics: // - If the caller is not admin or governance, or the token key is not registered. func (s *stakerV1) AddToken(_ int, rlm realm, tokenPath string) { access.AssertIsRlmCurrent(0, rlm) halt.AssertIsNotHaltedStaker() previousRealm := rlm.Previous() caller := previousRealm.Address() access.AssertIsAdminOrGovernance(caller) if err := checkCannotAddDefaultToken(tokenPath); err != nil { panic(err.Error()) } common.MustRegistered(tokenPath) if err := s.store.AddAllowedToken(0, rlm, tokenPath); err != nil { panic(err.Error()) } chain.Emit( "AddToken", "prevAddr", caller.String(), "prevRealm", previousRealm.PkgPath(), "tokenPath", tokenPath, ) } // RemoveToken removes a token path from the list of allowed tokens. // Only the admin can remove a token. // // Default tokens cannot be removed. // // Parameters: // - _: Leading realm-call discriminator; callers pass 0. // - rlm: Current staker realm context, validated before storage is changed. // - tokenPath: Token path to remove from the allowed-token list. // // Panics: // - If the caller is not admin or governance, or the token path is a protected default token. func (s *stakerV1) RemoveToken(_ int, rlm realm, tokenPath string) { access.AssertIsRlmCurrent(0, rlm) halt.AssertIsNotHaltedStaker() previousRealm := rlm.Previous() caller := previousRealm.Address() access.AssertIsAdminOrGovernance(caller) if err := checkCannotRemoveDefaultToken(tokenPath); err != nil { panic(err.Error()) } if err := s.store.RemoveAllowedToken(0, rlm, tokenPath); err != nil { panic(err.Error()) } chain.Emit( "RemoveToken", "prevAddr", caller.String(), "prevRealm", previousRealm.PkgPath(), "tokenPath", tokenPath, ) } func checkCannotAddDefaultToken(tokenPath string) error { if contains(sr.DefaultAllowedTokens(), tokenPath) { return ufmt.Errorf("%v: cannot add existing token(%s)", errAddExistingToken, tokenPath) } return nil } func checkCannotRemoveDefaultToken(tokenPath string) error { if contains(sr.DefaultAllowedTokens(), tokenPath) { return ufmt.Errorf("%v: cannot remove default token(%s)", errDefaultExternalToken, tokenPath) } return nil } // SetDeniedRewardToken sets or clears the operational deny flag for an external // incentive reward token. // // Pool-pair tokens qualify as reward tokens for their own pool without the // governance allowlist (a product policy), so the deny flag is the only lever // to stop NEW incentives in a pair token whose issuer turned hostile. The flag // is checked at incentive creation only: collection of already-created // incentives is deliberately unaffected, because the ledger-level delivery // guard in deliverExternalIncentiveReward already bounds their blast radius to // the hostile token itself. // // Default protocol tokens (GNS, WUGNOT) cannot be denied. // // Only the admin or governance can set the flag. // // Parameters: // - _: Leading realm-call discriminator; callers pass 0. // - rlm: Current staker realm context, validated before the deny list is changed. // - tokenPath: Reward-token path whose denied status is being changed. // - denied: True to add tokenPath to the deny list, false to remove it. func (s *stakerV1) SetDeniedRewardToken(_ int, rlm realm, tokenPath string, denied bool) { access.AssertIsRlmCurrent(0, rlm) halt.AssertIsNotHaltedStaker() previousRealm := rlm.Previous() caller := previousRealm.Address() access.AssertIsAdminOrGovernance(caller) if denied { if err := checkCannotDenyDefaultToken(tokenPath); err != nil { panic(err.Error()) } if err := s.store.AddDeniedRewardToken(0, rlm, tokenPath); err != nil { panic(err.Error()) } } else { if err := s.store.RemoveDeniedRewardToken(0, rlm, tokenPath); err != nil { panic(err.Error()) } } deniedStr := "false" if denied { deniedStr = "true" } chain.Emit( "SetDeniedRewardToken", "prevAddr", caller.String(), "prevRealm", previousRealm.PkgPath(), "tokenPath", tokenPath, "denied", deniedStr, ) } func checkCannotDenyDefaultToken(tokenPath string) error { if contains(sr.DefaultAllowedTokens(), tokenPath) { return ufmt.Errorf("%v: cannot deny default token(%s)", errCannotDenyDefaultToken, tokenPath) } return nil }
  24. #24getter.gno

Result log

msg:0,success:true,log:,events:[]