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