external_incentive.gno
14.53 Kb · 447 lines
1package v1
2
3import (
4 "chain"
5 "chain/runtime"
6 "time"
7
8 prbac "gno.land/p/gnoswap/rbac"
9 u256 "gno.land/p/gnoswap/uint256"
10 bptree "gno.land/p/nt/bptree/v0"
11 ufmt "gno.land/p/nt/ufmt/v0"
12
13 "gno.land/r/gnoswap/access"
14 "gno.land/r/gnoswap/common"
15 en "gno.land/r/gnoswap/emission"
16 "gno.land/r/gnoswap/gns"
17 "gno.land/r/gnoswap/halt"
18 sr "gno.land/r/gnoswap/staker"
19)
20
21// CreateExternalIncentive creates an external incentive program for a pool.
22//
23// Parameters:
24// - targetPoolPath: pool to incentivize
25// - rewardToken: reward token path
26// - rewardAmount: total reward amount
27// - startTimestamp, endTimestamp: incentive period
28//
29// Only callable by users.
30func (s *stakerV1) CreateExternalIncentive(
31 _ int,
32 rlm realm,
33 targetPoolPath string,
34 rewardToken string, // token path should be registered
35 rewardAmount int64,
36 startTimestamp int64,
37 endTimestamp int64,
38) {
39 if !rlm.IsCurrent() {
40 panic(errSpoofedRealm)
41 }
42
43 halt.AssertIsNotHaltedStaker()
44
45 prevRealm := rlm.Previous()
46 caller := prevRealm.Address()
47 access.AssertIsUser(0, prevRealm)
48
49 assertIsPoolExists(s, targetPoolPath)
50
51 assertIsGreaterThanMinimumRewardAmount(s, rewardToken, rewardAmount)
52 assertIsAllowedForExternalReward(s, targetPoolPath, rewardToken)
53 assertIsValidIncentiveStartTime(startTimestamp)
54 assertIsValidIncentiveEndTime(endTimestamp)
55 assertIsValidIncentiveDuration(safeSubInt64(endTimestamp, startTimestamp))
56 // assert that the user has sent the correct amount of native coin
57 common.AssertIsNotHandleNativeCoin()
58
59 en.MintAndDistributeGns(cross(rlm))
60
61 stakerAddr := access.MustGetAddress(prbac.ROLE_STAKER.String())
62
63 // transfer reward token from user to staker
64 common.SafeGRC20TransferFrom(cross(rlm), rewardToken, caller, stakerAddr, rewardAmount)
65
66 depositGnsAmount := s.store.GetDepositGnsAmount()
67
68 // deposit gns amount
69 gns.TransferFrom(cross(rlm), caller, stakerAddr, depositGnsAmount)
70
71 currentTime := time.Now().Unix()
72 currentHeight := runtime.ChainHeight()
73 incentiveId := s.store.NextIncentiveID(caller, currentTime)
74 pool := s.getPools().GetPoolOrNil(targetPoolPath)
75 if pool == nil {
76 pool = sr.NewPool(targetPoolPath, currentTime)
77 s.getPools().set(targetPoolPath, pool)
78 }
79
80 incentive := sr.NewExternalIncentive(
81 incentiveId,
82 targetPoolPath,
83 rewardToken,
84 rewardAmount,
85 startTimestamp,
86 endTimestamp,
87 caller,
88 depositGnsAmount,
89 currentHeight,
90 currentTime,
91 )
92
93 externalIncentives := s.store.GetExternalIncentives()
94 if externalIncentives.Has(incentiveId) {
95 panic(makeErrorWithDetails(
96 errIncentiveAlreadyExists,
97 ufmt.Sprintf("incentiveId(%s)", incentiveId),
98 ))
99 }
100 // store external incentive information for each incentiveId
101 externalIncentives.Set(incentiveId, incentive)
102
103 poolResolver := NewPoolResolver(pool)
104 poolResolver.IncentivesResolver().create(incentive)
105
106 // add incentive to time-based index for lazy discovery during CollectReward
107 s.addIncentiveIdByCreationTime(0, rlm, targetPoolPath, incentiveId, currentTime)
108
109 chain.Emit(
110 "CreateExternalIncentive",
111 "prevAddr", caller.String(),
112 "prevRealm", prevRealm.PkgPath(),
113 "incentiveId", incentiveId,
114 "targetPoolPath", targetPoolPath,
115 "rewardToken", rewardToken,
116 "rewardAmount", formatAnyInt(rewardAmount),
117 "startTimestamp", formatAnyInt(startTimestamp),
118 "endTimestamp", formatAnyInt(endTimestamp),
119 "depositGnsAmount", formatAnyInt(depositGnsAmount),
120 "currentHeight", formatAnyInt(currentHeight),
121 "currentTime", formatAnyInt(currentTime),
122 )
123}
124
125// EndExternalIncentive ends an external incentive and refunds remaining rewards.
126//
127// Finalizes incentive program after end timestamp.
128// Returns unallocated rewards and GNS deposit.
129// Calculates unclaimable rewards for refund.
130//
131// Parameters:
132// - targetPoolPath: Pool with the incentive
133// - incentiveId: Unique incentive identifier
134//
135// Process:
136// 1. Validates incentive end time reached
137// 2. Calculates remaining and unclaimable rewards
138// 3. Refunds rewards to original creator
139// 4. Returns 100 GNS deposit
140// 5. Removes incentive from active list
141//
142// Only callable by Creator or Admin.
143func (s *stakerV1) EndExternalIncentive(_ int, rlm realm, targetPoolPath, incentiveId string, refundAddress address) {
144 if !rlm.IsCurrent() {
145 panic(errSpoofedRealm)
146 }
147
148 halt.AssertIsNotHaltedWithdraw()
149
150 // checks pool registry
151 assertIsPoolExists(s, targetPoolPath)
152 assertIsValidAddress(refundAddress)
153
154 // checks if the pool has been incentivized
155 pool, exists := s.getPools().Get(targetPoolPath)
156 if !exists {
157 panic(makeErrorWithDetails(
158 errDataNotFound,
159 ufmt.Sprintf("targetPoolPath(%s) not found", targetPoolPath),
160 ))
161 }
162
163 poolResolver := NewPoolResolver(pool)
164 incentivesResolver := poolResolver.IncentivesResolver()
165
166 // Get incentive to check if GNS already refunded
167 incentiveResolver, exists := incentivesResolver.GetIncentiveResolver(incentiveId)
168 if !exists {
169 panic(makeErrorWithDetails(
170 errCannotEndIncentive,
171 ufmt.Sprintf("cannot end non existent incentive(%s)", incentiveId),
172 ))
173 }
174
175 // Check if incentive has already been refunded
176 if incentiveResolver.Refunded() {
177 panic(makeErrorWithDetails(
178 errCannotEndIncentive,
179 ufmt.Sprintf("incentive(%s) has already been refunded", incentiveId),
180 ))
181 }
182
183 caller := rlm.Previous().Address()
184
185 // Process ending
186 incentive, refund, err := s.endExternalIncentive(poolResolver, incentiveResolver, caller, time.Now().Unix())
187 if err != nil {
188 panic(err)
189 }
190
191 stakerAddr := access.MustGetAddress(prbac.ROLE_STAKER.String())
192 poolLeftExternalRewardAmount := common.BalanceOf(incentiveResolver.RewardToken(), stakerAddr)
193 if poolLeftExternalRewardAmount < refund {
194 previousRealm := rlm.Previous()
195 chain.Emit(
196 "EndExternalIncentiveShortfall",
197 "prevAddr", previousRealm.Address().String(),
198 "prevRealm", previousRealm.PkgPath(),
199 "incentiveId", incentiveId,
200 "targetPoolPath", targetPoolPath,
201 "refundee", refundAddress.String(),
202 "refundToken", incentiveResolver.RewardToken(),
203 "expectedRefundAmount", formatAnyInt(refund),
204 "actualRefundAmount", formatAnyInt(poolLeftExternalRewardAmount),
205 "creator", incentiveResolver.Creator().String(),
206 )
207 refund = poolLeftExternalRewardAmount
208 }
209
210 // Mark incentive as refunded and update
211 // After this update, attempts to re-claim GNS or rewards that were deposited
212 // through the `endExternalIncentive` function will be blocked.
213 incentiveResolver.SetRefunded(true)
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", formatAnyInt(refund),
233 "refundGnsAmount", formatAnyInt(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 := 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 := safeConvertToInt64(distributableU256)
276 remainder := safeSubInt64(incentiveResolver.TotalRewardAmount(), distributable)
277
278 refund := safeAddInt64(unclaimableReward, remainder)
279
280 maxRefund := safeSubInt64(incentiveResolver.TotalRewardAmount(), incentiveResolver.DistributedRewardAmount())
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(errSpoofedRealm)
311 }
312
313 halt.AssertIsNotHaltedWithdraw()
314
315 assertIsPoolExists(s, targetPoolPath)
316 assertIsValidAddress(refundAddress)
317
318 pool, exists := s.getPools().Get(targetPoolPath)
319 if !exists {
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", formatAnyInt(penaltyAmount),
372 "actualPenaltyAmount", formatAnyInt(balance),
373 "creator", incentiveResolver.Creator().String(),
374 )
375 penaltyAmount = balance
376 }
377
378 // Reset accumulated penalty
379 incentiveResolver.SetAccumulatedPenaltyAmount(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", formatAnyInt(penaltyAmount),
394 )
395
396 return penaltyAmount
397}
398
399// addIncentiveIdByCreationTime adds an external incentive to the time-based index.
400//
401// The index structure is:
402// - creationTime (int64) -> poolPath (string) -> []incentiveIds
403func (s *stakerV1) addIncentiveIdByCreationTime(_ int, rlm realm, poolPath, incentiveId string, creationTime int64) {
404 incentivesByTime := s.getExternalIncentivesByCreationTime()
405
406 // Rebuild the nested poolPath -> []incentiveId tree into a freshly allocated
407 // bptree on every call instead of mutating the one fetched out of the
408 // persisted UintTree. A persisted nested tree is owned by /r/gnoswap/staker
409 // and a direct bptree.Set on it from a different calling realm trips the
410 // readonly-taint gate (the bptree Set runs in the caller's realm, not the
411 // tree's owning realm). Allocating a fresh tree here makes it owned by the
412 // executing realm, so its leaf writes are always permitted; re-Setting all
413 // existing entries preserves the stored shape.
414 freshPoolIncentiveIds := sr.NewBPTreeN(16)
415 found := false
416
417 if currentPoolIncentiveIdsValue, exists := incentivesByTime.Get(creationTime); exists {
418 currentPoolIncentiveIds, ok := currentPoolIncentiveIdsValue.(*bptree.BPTree)
419 if !ok {
420 panic(ufmt.Sprintf("invalid type in incentivesByTime tree: expected *bptree.BPTree, got %T", currentPoolIncentiveIdsValue))
421 }
422
423 currentPoolIncentiveIds.Iterate("", "", func(key string, value any) bool {
424 incentiveIds, ok := value.([]string)
425 if !ok {
426 panic(ufmt.Sprintf("invalid type in incentivesByTime tree: expected []string, got %T", value))
427 }
428
429 copied := make([]string, len(incentiveIds))
430 copy(copied, incentiveIds)
431 if key == poolPath {
432 copied = append(copied, incentiveId)
433 found = true
434 }
435 freshPoolIncentiveIds.Set(key, copied)
436 return false
437 })
438 }
439
440 if !found {
441 freshPoolIncentiveIds.Set(poolPath, []string{incentiveId})
442 }
443
444 incentivesByTime.Set(creationTime, freshPoolIncentiveIds)
445
446 s.updateExternalIncentivesByCreationTime(0, rlm, incentivesByTime)
447}