external_incentive.gno
12.60 Kb · 397 lines
1package staker
2
3import (
4 "errors"
5 "chain"
6 "chain/runtime"
7 "time"
8
9 "gno.land/p/gnoswap/gnsmath"
10 prbac "gno.land/p/gnoswap/rbac"
11 u256 "gno.land/p/gnoswap/uint256"
12 "gno.land/p/gnoswap/utils"
13 ufmt "gno.land/p/nt/ufmt/v0"
14
15 "gno.land/r/gnoswap/access"
16 "gno.land/r/gnoswap/common"
17 en "gno.land/r/gnoswap/emission"
18 "gno.land/r/gnoswap/gns"
19 "gno.land/r/gnoswap/halt"
20 sr "gno.land/r/gnoswap/staker"
21)
22
23// CreateExternalIncentive creates an external incentive program for a pool.
24//
25// Parameters:
26// - targetPoolPath: pool to incentivize
27// - rewardToken: reward token path
28// - rewardAmount: total reward amount
29// - startTimestamp, endTimestamp: incentive period
30//
31// Only callable by admin.
32func (s *stakerV1) CreateExternalIncentive(
33 _ int,
34 rlm realm,
35 targetPoolPath string,
36 rewardToken string, // token path should be registered
37 rewardAmount int64,
38 startTimestamp int64,
39 endTimestamp int64,
40) {
41 if !rlm.IsCurrent() {
42 panic(errors.New(errSpoofedRealm))
43 }
44
45 halt.AssertIsNotHaltedStaker()
46
47 prevRealm := rlm.Previous()
48 caller := prevRealm.Address()
49 access.AssertIsAdmin(caller)
50
51 assertIsPoolExists(s, targetPoolPath)
52
53 assertIsGreaterThanMinimumRewardAmount(s, rewardToken, rewardAmount)
54 assertIsAllowedForExternalReward(s, targetPoolPath, rewardToken)
55 assertIsValidIncentiveStartTime(startTimestamp)
56 assertIsValidIncentiveEndTime(endTimestamp)
57 assertIsValidIncentiveDuration(gnsmath.SafeSubInt64(endTimestamp, startTimestamp))
58 // assert that the user has sent the correct amount of native coin
59 common.AssertIsNotHandleNativeCoin()
60
61 en.MintAndDistributeGns(cross(rlm))
62
63 stakerAddr := access.MustGetAddress(prbac.ROLE_STAKER.String())
64
65 // transfer reward token from user to staker
66 common.SafeGRC20TransferFrom(cross(rlm), rewardToken, caller, stakerAddr, rewardAmount)
67
68 depositGnsAmount := s.store.GetDepositGnsAmount()
69
70 // deposit gns amount
71 gns.TransferFrom(cross(rlm), caller, stakerAddr, depositGnsAmount)
72
73 currentTime := time.Now().Unix()
74 currentHeight := runtime.ChainHeight()
75 incentiveId := s.store.NextIncentiveID(caller, currentTime)
76 pool := s.getPools().GetPoolOrNil(targetPoolPath)
77 if pool == nil {
78 pool = sr.NewPool(targetPoolPath, currentTime)
79 s.getPools().set(targetPoolPath, pool)
80 }
81
82 incentive := sr.NewExternalIncentive(
83 incentiveId,
84 targetPoolPath,
85 rewardToken,
86 rewardAmount,
87 startTimestamp,
88 endTimestamp,
89 caller,
90 depositGnsAmount,
91 currentHeight,
92 currentTime,
93 )
94
95 externalIncentives := s.store.GetExternalIncentives()
96 if externalIncentives.Has(incentiveId) {
97 panic(makeErrorWithDetails(
98 errIncentiveAlreadyExists,
99 ufmt.Sprintf("incentiveId(%s)", incentiveId),
100 ))
101 }
102 // store external incentive information for each incentiveId
103 externalIncentives.Set(incentiveId, incentive)
104
105 poolResolver := NewPoolResolver(pool)
106 poolResolver.IncentivesResolver().create(incentive)
107
108 chain.Emit(
109 "CreateExternalIncentive",
110 "prevAddr", caller.String(),
111 "prevRealm", prevRealm.PkgPath(),
112 "incentiveId", incentiveId,
113 "targetPoolPath", targetPoolPath,
114 "rewardToken", rewardToken,
115 "rewardAmount", utils.FormatInt(rewardAmount),
116 "startTimestamp", utils.FormatInt(startTimestamp),
117 "endTimestamp", utils.FormatInt(endTimestamp),
118 "depositGnsAmount", utils.FormatInt(depositGnsAmount),
119 "currentHeight", utils.FormatInt(currentHeight),
120 "currentTime", utils.FormatInt(currentTime),
121 )
122}
123
124// EndExternalIncentive ends an external incentive and refunds remaining rewards.
125//
126// Finalizes incentive program after end timestamp.
127// Returns unallocated rewards and GNS deposit.
128// Calculates unclaimable rewards for refund.
129//
130// Parameters:
131// - targetPoolPath: Pool with the incentive
132// - incentiveId: Unique incentive identifier
133//
134// Process:
135// 1. Validates incentive end time reached
136// 2. Calculates remaining and unclaimable rewards
137// 3. Refunds rewards to original creator
138// 4. Returns 100 GNS deposit
139// 5. Removes incentive from active list
140//
141// Only callable by Creator or Admin.
142func (s *stakerV1) EndExternalIncentive(_ int, rlm realm, targetPoolPath, incentiveId string, refundAddress address) {
143 if !rlm.IsCurrent() {
144 panic(errors.New(errSpoofedRealm))
145 }
146
147 halt.AssertIsNotHaltedWithdraw()
148
149 // checks pool registry
150 assertIsPoolExists(s, targetPoolPath)
151 assertIsValidAddress(refundAddress)
152
153 // checks if the pool has been incentivized
154 pool, ok := s.getPools().Get(targetPoolPath)
155 if !ok {
156 panic(makeErrorWithDetails(
157 errDataNotFound,
158 ufmt.Sprintf("targetPoolPath(%s) not found", targetPoolPath),
159 ))
160 }
161
162 poolResolver := NewPoolResolver(pool)
163 incentivesResolver := poolResolver.IncentivesResolver()
164
165 // Get incentive to check if GNS already refunded
166 incentiveResolver, exists := incentivesResolver.GetIncentiveResolver(incentiveId)
167 if !exists {
168 panic(makeErrorWithDetails(
169 errCannotEndIncentive,
170 ufmt.Sprintf("cannot end non existent incentive(%s)", incentiveId),
171 ))
172 }
173
174 // Check if incentive has already been refunded
175 if incentiveResolver.Refunded() {
176 panic(makeErrorWithDetails(
177 errCannotEndIncentive,
178 ufmt.Sprintf("incentive(%s) has already been refunded", incentiveId),
179 ))
180 }
181
182 caller := rlm.Previous().Address()
183
184 // Process ending
185 incentive, refund, err := s.endExternalIncentive(poolResolver, incentiveResolver, caller, time.Now().Unix())
186 if err != nil {
187 panic(err)
188 }
189
190 stakerAddr := access.MustGetAddress(prbac.ROLE_STAKER.String())
191 poolLeftExternalRewardAmount := common.BalanceOf(incentiveResolver.RewardToken(), stakerAddr)
192 if poolLeftExternalRewardAmount < refund {
193 previousRealm := rlm.Previous()
194 chain.Emit(
195 "EndExternalIncentiveShortfall",
196 "prevAddr", previousRealm.Address().String(),
197 "prevRealm", previousRealm.PkgPath(),
198 "incentiveId", incentiveId,
199 "targetPoolPath", targetPoolPath,
200 "refundee", refundAddress.String(),
201 "refundToken", incentiveResolver.RewardToken(),
202 "expectedRefundAmount", utils.FormatInt(refund),
203 "actualRefundAmount", utils.FormatInt(poolLeftExternalRewardAmount),
204 "creator", incentiveResolver.Creator().String(),
205 )
206 refund = poolLeftExternalRewardAmount
207 }
208
209 // Mark incentive as refunded and update
210 // After this update, attempts to re-claim GNS or rewards that were deposited
211 // through the `endExternalIncentive` function will be blocked.
212 incentiveResolver.SetRefunded(true)
213 incentiveResolver.SetRewardAmount(gnsmath.SafeSubInt64(incentiveResolver.RewardAmount(), refund))
214 incentiveResolver.addDistributedRewardAmount(refund)
215 incentivesResolver.update(incentive)
216
217 // refund reward token to refundee
218 common.SafeGRC20Transfer(cross(rlm), incentiveResolver.RewardToken(), refundAddress, refund)
219
220 // Transfer GNS deposit back to refundee
221 gns.Transfer(cross(rlm), refundAddress, incentiveResolver.DepositGnsAmount())
222
223 previousRealm := rlm.Previous()
224 chain.Emit(
225 "EndExternalIncentive",
226 "prevAddr", previousRealm.Address().String(),
227 "prevRealm", previousRealm.PkgPath(),
228 "incentiveId", incentiveId,
229 "targetPoolPath", targetPoolPath,
230 "refundee", refundAddress.String(),
231 "refundToken", incentiveResolver.RewardToken(),
232 "refundAmount", utils.FormatInt(refund),
233 "refundGnsAmount", utils.FormatInt(incentiveResolver.DepositGnsAmount()),
234 "externalIncentiveEndBy", previousRealm.Address().String(),
235 "creator", incentiveResolver.Creator().String(),
236 )
237}
238
239// endExternalIncentive processes the end of an external incentive program.
240func (s *stakerV1) endExternalIncentive(resolver *PoolResolver, incentiveResolver *ExternalIncentiveResolver, caller address, currentTime int64) (*sr.ExternalIncentive, int64, error) {
241 if currentTime < incentiveResolver.EndTimestamp() {
242 return nil, 0, makeErrorWithDetails(
243 errCannotEndIncentive,
244 ufmt.Sprintf("cannot end incentive before endTime(%d), current(%d)", incentiveResolver.EndTimestamp(), currentTime),
245 )
246 }
247
248 // only creator or admin can end incentive
249 if !access.IsAuthorized(prbac.ROLE_ADMIN.String(), caller) && caller != incentiveResolver.Creator() {
250 adminAddr := access.MustGetAddress(prbac.ROLE_ADMIN.String())
251 return nil, 0, makeErrorWithDetails(
252 errNoPermission,
253 ufmt.Sprintf(
254 "only creator(%s) or admin(%s) can end incentive, but called from %s",
255 incentiveResolver.Creator(), adminAddr.String(), caller,
256 ),
257 )
258 }
259
260 // refund = unclaimableReward + remainder + accumulatedPenaltyAmount
261 incentivesResolver := resolver.IncentivesResolver()
262 unclaimableReward := incentivesResolver.calculateUnclaimableReward(incentiveResolver.IncentiveId())
263
264 duration := gnsmath.SafeSubInt64(incentiveResolver.EndTimestamp(), incentiveResolver.StartTimestamp())
265 // distributable = floor((rewardPerSecondX128 * duration) / 2^128).
266 // With Q128 scaling the truncation per second collapses to at most 1 wei
267 // across the entire duration, so `remainder` is effectively zero and the
268 // refund accounts only for unclaimable periods.
269 distributableU256 := u256.MulDiv(
270 incentiveResolver.RewardPerSecondX128(),
271 u256.NewUintFromInt64(duration),
272 q128,
273 )
274
275 distributable := gnsmath.SafeConvertToInt64(distributableU256)
276 remainder := gnsmath.SafeSubInt64(incentiveResolver.TotalRewardAmount(), distributable)
277
278 refund := gnsmath.SafeAddInt64(unclaimableReward, remainder)
279
280 maxRefund := incentiveResolver.RewardAmount()
281 if refund > maxRefund {
282 refund = maxRefund
283 }
284
285 if refund < 0 {
286 return nil, 0, makeErrorWithDetails(
287 errCalculationError,
288 ufmt.Sprintf("refund should never be negative: Got %d", refund),
289 )
290 }
291
292 return incentiveResolver.ExternalIncentive, refund, nil
293}
294
295// CollectExternalIncentivePenalty collects accumulated warmup penalties
296// for a specific ended external incentive.
297// Penalties are accumulated during CollectReward and stored in the incentive.
298// This function transfers the accumulated penalty to the specified refund address.
299// Returns the penalty amount collected.
300//
301// Only callable by the incentive creator or admin.
302func (s *stakerV1) CollectExternalIncentivePenalty(
303 _ int,
304 rlm realm,
305 targetPoolPath string,
306 incentiveId string,
307 refundAddress address,
308) int64 {
309 if !rlm.IsCurrent() {
310 panic(errors.New(errSpoofedRealm))
311 }
312
313 halt.AssertIsNotHaltedWithdraw()
314
315 assertIsPoolExists(s, targetPoolPath)
316 assertIsValidAddress(refundAddress)
317
318 pool, ok := s.getPools().Get(targetPoolPath)
319 if !ok {
320 panic(makeErrorWithDetails(
321 errDataNotFound,
322 ufmt.Sprintf("targetPoolPath(%s) not found", targetPoolPath),
323 ))
324 }
325
326 poolResolver := NewPoolResolver(pool)
327 incentivesResolver := poolResolver.IncentivesResolver()
328
329 incentiveResolver, exists := incentivesResolver.GetIncentiveResolver(incentiveId)
330 if !exists {
331 panic(makeErrorWithDetails(
332 errDataNotFound,
333 ufmt.Sprintf("incentive(%s) not found", incentiveId),
334 ))
335 }
336
337 if !incentiveResolver.Refunded() {
338 panic(makeErrorWithDetails(
339 errIsNotEndedIncentive,
340 ufmt.Sprintf("incentive(%s) must be ended first (call EndExternalIncentive)", incentiveId),
341 ))
342 }
343
344 caller := rlm.Previous().Address()
345 if !access.IsAuthorized(prbac.ROLE_ADMIN.String(), caller) && caller != incentiveResolver.Creator() {
346 adminAddr := access.MustGetAddress(prbac.ROLE_ADMIN.String())
347 panic(makeErrorWithDetails(
348 errNoPermission,
349 ufmt.Sprintf("only creator(%s) or admin(%s) can collect penalty, but called from %s", incentiveResolver.Creator(), adminAddr.String(), caller),
350 ))
351 }
352
353 penaltyAmount := incentiveResolver.AccumulatedPenaltyAmount()
354 if penaltyAmount == 0 {
355 return 0
356 }
357
358 // Cap by actual staker balance
359 stakerAddr := access.MustGetAddress(prbac.ROLE_STAKER.String())
360 balance := common.BalanceOf(incentiveResolver.RewardToken(), stakerAddr)
361 if balance < penaltyAmount {
362 previousRealm := rlm.Previous()
363 chain.Emit(
364 "CollectExternalIncentivePenaltyShortfall",
365 "prevAddr", previousRealm.Address().String(),
366 "prevRealm", previousRealm.PkgPath(),
367 "targetPoolPath", targetPoolPath,
368 "incentiveId", incentiveId,
369 "refundAddress", refundAddress.String(),
370 "refundToken", incentiveResolver.RewardToken(),
371 "expectedPenaltyAmount", utils.FormatInt(penaltyAmount),
372 "actualPenaltyAmount", utils.FormatInt(balance),
373 "creator", incentiveResolver.Creator().String(),
374 )
375 penaltyAmount = balance
376 }
377
378 // Reset accumulated penalty
379 incentiveResolver.SetAccumulatedPenaltyAmount(gnsmath.SafeSubInt64(incentiveResolver.AccumulatedPenaltyAmount(), penaltyAmount))
380 incentivesResolver.update(incentiveResolver.ExternalIncentive)
381
382 // Transfer penalty to refund address
383 common.SafeGRC20Transfer(cross(rlm), incentiveResolver.RewardToken(), refundAddress, penaltyAmount)
384
385 previousRealm := rlm.Previous()
386 chain.Emit(
387 "CollectExternalIncentivePenalty",
388 "prevAddr", previousRealm.Address().String(),
389 "prevRealm", previousRealm.PkgPath(),
390 "targetPoolPath", targetPoolPath,
391 "incentiveId", incentiveId,
392 "refundAddress", refundAddress.String(),
393 "penaltyAmount", utils.FormatInt(penaltyAmount),
394 )
395
396 return penaltyAmount
397}