external_incentive.gno
12.24 Kb ยท 376 lines
1package v1
2
3import (
4 "chain"
5 "chain/runtime"
6 "math"
7 "time"
8
9 prbac "gno.land/p/gnoswap/rbac"
10 "gno.land/p/nt/avl"
11 "gno.land/p/nt/ufmt"
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 targetPoolPath string,
32 rewardToken string, // token path should be registered
33 rewardAmount int64,
34 startTimestamp int64,
35 endTimestamp int64,
36) {
37 halt.AssertIsNotHaltedStaker()
38
39 prevRealm := runtime.PreviousRealm()
40 caller := prevRealm.Address()
41 access.AssertIsUser(prevRealm)
42
43 assertIsPoolExists(s, targetPoolPath)
44 assertIsGreaterThanMinimumRewardAmount(s, rewardToken, rewardAmount)
45 assertIsAllowedForExternalReward(s, targetPoolPath, rewardToken)
46 assertIsValidIncentiveStartTime(startTimestamp)
47 assertIsValidIncentiveEndTime(endTimestamp)
48 assertIsValidIncentiveDuration(safeSubInt64(endTimestamp, startTimestamp))
49 // assert that the user has sent the correct amount of native coin
50 assertIsValidUserCoinSend(rewardToken, rewardAmount)
51
52 en.MintAndDistributeGns(cross)
53
54 stakerAddr := access.MustGetAddress(prbac.ROLE_STAKER.String())
55
56 // transfer reward token from user to staker
57 isRequestUnwrap := false
58 if rewardToken == GNOT_DENOM {
59 isRequestUnwrap = true
60 rewardToken = WUGNOT_PATH
61 err := wrapWithTransfer(stakerAddr, rewardAmount)
62 if err != nil {
63 panic(err)
64 }
65 } else {
66 common.SafeGRC20TransferFrom(cross, rewardToken, caller, stakerAddr, rewardAmount)
67 }
68
69 depositGnsAmount := s.store.GetDepositGnsAmount()
70
71 // deposit gns amount
72 gns.TransferFrom(cross, 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 isRequestUnwrap,
95 )
96
97 externalIncentives := s.store.GetExternalIncentives()
98 if externalIncentives.Has(incentiveId) {
99 panic(makeErrorWithDetails(
100 errIncentiveAlreadyExists,
101 ufmt.Sprintf("incentiveId(%s)", incentiveId),
102 ))
103 }
104 // store external incentive information for each incentiveId
105 externalIncentives.Set(incentiveId, incentive)
106
107 poolResolver := NewPoolResolver(pool)
108 poolResolver.IncentivesResolver().create(caller, incentive)
109
110 // add incentive to time-based index for lazy discovery during CollectReward
111 s.addIncentiveIdByCreationTime(targetPoolPath, incentiveId, currentTime)
112
113 chain.Emit(
114 "CreateExternalIncentive",
115 "prevAddr", caller.String(),
116 "prevRealm", prevRealm.PkgPath(),
117 "incentiveId", incentiveId,
118 "targetPoolPath", targetPoolPath,
119 "rewardToken", rewardToken,
120 "rewardAmount", formatAnyInt(rewardAmount),
121 "startTimestamp", formatAnyInt(startTimestamp),
122 "endTimestamp", formatAnyInt(endTimestamp),
123 "depositGnsAmount", formatAnyInt(depositGnsAmount),
124 "currentHeight", formatAnyInt(currentHeight),
125 "currentTime", formatAnyInt(currentTime),
126 )
127}
128
129// EndExternalIncentive ends an external incentive and refunds remaining rewards.
130//
131// Finalizes incentive program after end timestamp.
132// Returns unallocated rewards and GNS deposit.
133// Calculates unclaimable rewards for refund.
134//
135// Parameters:
136// - targetPoolPath: Pool with the incentive
137// - incentiveId: Unique incentive identifier
138//
139// Process:
140// 1. Validates incentive end time reached
141// 2. Calculates remaining and unclaimable rewards
142// 3. Refunds rewards to original creator
143// 4. Returns 100 GNS deposit
144// 5. Removes incentive from active list
145//
146// Only callable by Refundee or Admin.
147func (s *stakerV1) EndExternalIncentive(targetPoolPath, incentiveId string) {
148 halt.AssertIsNotHaltedStaker()
149 halt.AssertIsNotHaltedWithdraw()
150
151 // checks pool registry
152 assertIsPoolExists(s, targetPoolPath)
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 := runtime.PreviousRealm().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 // remove incentive from time-based index
192 s.removeIncentiveIdByCreationTime(targetPoolPath, incentiveId, incentive.CreatedTimestamp())
193
194 stakerAddr := access.MustGetAddress(prbac.ROLE_STAKER.String())
195 poolLeftExternalRewardAmount := common.BalanceOf(incentiveResolver.RewardToken(), stakerAddr)
196 if poolLeftExternalRewardAmount < refund {
197 refund = poolLeftExternalRewardAmount
198 }
199
200 // handle refund based on original token type
201 if incentiveResolver.IsRequestUnwrap() {
202 // unwrap to GNOT if originally deposited as native GNOT
203 transferErr := unwrapWithTransfer(incentiveResolver.Refundee(), refund)
204 if transferErr != nil {
205 panic(transferErr)
206 }
207 } else {
208 // keep as WUGNOT or other token if originally deposited as wrapped token
209 common.SafeGRC20Transfer(cross, incentiveResolver.RewardToken(), incentiveResolver.Refundee(), refund)
210 }
211
212 // Transfer GNS deposit back to refundee
213 gns.Transfer(cross, incentiveResolver.Refundee(), incentiveResolver.DepositGnsAmount())
214
215 // Mark incentive as refunded and update
216 // After this update, attempts to re-claim GNS or rewards that were deposited
217 // through the `endExternalIncentive` function will be blocked.
218 incentiveResolver.SetRefunded(true)
219 incentiveResolver.addDistributedRewardAmount(refund)
220 incentivesResolver.update(incentive.Refundee(), incentive)
221
222 previousRealm := runtime.PreviousRealm()
223 chain.Emit(
224 "EndExternalIncentive",
225 "prevAddr", previousRealm.Address().String(),
226 "prevRealm", previousRealm.PkgPath(),
227 "incentiveId", incentiveId,
228 "targetPoolPath", targetPoolPath,
229 "refundee", incentiveResolver.Refundee().String(),
230 "refundToken", incentiveResolver.RewardToken(),
231 "refundAmount", formatAnyInt(refund),
232 "refundGnsAmount", formatAnyInt(incentiveResolver.DepositGnsAmount()),
233 "isRequestUnwrap", formatBool(incentiveResolver.IsRequestUnwrap()),
234 "externalIncentiveEndBy", previousRealm.Address().String(),
235 )
236}
237
238// endExternalIncentive processes the end of an external incentive program.
239func (s *stakerV1) endExternalIncentive(resolver *PoolResolver, incentiveResolver *ExternalIncentiveResolver, caller address, currentTime int64) (*sr.ExternalIncentive, int64, error) {
240 if currentTime < incentiveResolver.EndTimestamp() {
241 return nil, 0, makeErrorWithDetails(
242 errCannotEndIncentive,
243 ufmt.Sprintf("cannot end incentive before endTime(%d), current(%d)", incentiveResolver.EndTimestamp(), currentTime),
244 )
245 }
246
247 // only refundee or admin can end incentive
248 if !access.IsAuthorized(prbac.ROLE_ADMIN.String(), caller) && caller != incentiveResolver.Refundee() {
249 adminAddr := access.MustGetAddress(prbac.ROLE_ADMIN.String())
250 return nil, 0, makeErrorWithDetails(
251 errNoPermission,
252 ufmt.Sprintf(
253 "only refundee(%s) or admin(%s) can end incentive, but called from %s",
254 incentiveResolver.Refundee(), adminAddr.String(), caller,
255 ),
256 )
257 }
258
259 totalReward := int64(0)
260
261 // calculate total external reward for the incentive
262 s.getDeposits().IterateByPoolPath(0, math.MaxUint64, incentiveResolver.TargetPoolPath(), func(positionId uint64, deposit *sr.Deposit) bool {
263 depositResolver := NewDepositResolver(deposit)
264 lastCollectTime := depositResolver.ExternalRewardLastCollectTime(incentiveResolver.IncentiveId())
265
266 if lastCollectTime > incentiveResolver.EndTimestamp() {
267 return false
268 }
269
270 rewardState := resolver.RewardStateOf(deposit)
271 calculatedTotalReward := rewardState.calculateCollectableExternalReward(lastCollectTime, currentTime, incentiveResolver.ExternalIncentive)
272 totalReward = safeAddInt64(totalReward, calculatedTotalReward)
273
274 return false
275 })
276
277 // calculate refund amount is the difference between the incentive reward amount and the total external reward
278 refund := safeSubInt64(incentiveResolver.TotalRewardAmount(), totalReward)
279 refund = safeSubInt64(refund, incentiveResolver.DistributedRewardAmount())
280
281 return incentiveResolver.ExternalIncentive, refund, nil
282}
283
284// addIncentiveIdByCreationTime adds an external incentive to the time-based index.
285//
286// The index structure is:
287// - creationTime (int64) -> poolPath (string) -> []incentiveIds
288func (s *stakerV1) addIncentiveIdByCreationTime(poolPath, incentiveId string, creationTime int64) {
289 incentivesByTime := s.getExternalIncentivesByCreationTime()
290
291 currentPoolIncentiveIdsValue, exists := incentivesByTime.Get(creationTime)
292 if !exists {
293 currentPoolIncentiveIdsValue = avl.NewTree()
294 }
295
296 currentPoolIncentiveIds, ok := currentPoolIncentiveIdsValue.(*avl.Tree)
297 if !ok {
298 panic(ufmt.Sprintf("invalid type in incentivesByTime tree: expected *avl.Tree, got %T", currentPoolIncentiveIdsValue))
299 }
300
301 incentiveIdsValue, exists := currentPoolIncentiveIds.Get(poolPath)
302 if !exists {
303 incentiveIdsValue = []string{}
304 }
305
306 incentiveIds, ok := incentiveIdsValue.([]string)
307 if !ok {
308 panic(ufmt.Sprintf("invalid type in incentivesByTime tree: expected []string, got %T", incentiveIdsValue))
309 }
310
311 incentiveIds = append(incentiveIds, incentiveId)
312 currentPoolIncentiveIds.Set(poolPath, incentiveIds)
313 incentivesByTime.Set(creationTime, currentPoolIncentiveIds)
314
315 s.updateExternalIncentivesByCreationTime(incentivesByTime)
316}
317
318// removeIncentiveIdByCreationTime removes an incentive from the time-based index.
319// This is called when an external incentive is ended via EndExternalIncentive.
320//
321// The index structure is:
322// - creationTime (int64) -> poolPath (string) -> []incentiveId
323func (s *stakerV1) removeIncentiveIdByCreationTime(poolPath, incentiveId string, creationTime int64) {
324 incentivesByTime := s.getExternalIncentivesByCreationTime()
325
326 // check if creation time exists
327 currentPoolIncentiveIdsValue, exists := incentivesByTime.Get(creationTime)
328 if !exists {
329 return
330 }
331
332 currentPoolIncentiveIds, ok := currentPoolIncentiveIdsValue.(*avl.Tree)
333 if !ok {
334 panic(ufmt.Sprintf("invalid type in incentivesByTime tree: expected *avl.Tree, got %T", currentPoolIncentiveIdsValue))
335 }
336
337 // check if pool path exists at this time
338 incentiveIdsValue, exists := currentPoolIncentiveIds.Get(poolPath)
339 if !exists {
340 return
341 }
342
343 incentiveIds, ok := incentiveIdsValue.([]string)
344 if !ok {
345 panic(ufmt.Sprintf("invalid type in incentivesByTime tree: expected []string, got %T", incentiveIdsValue))
346 }
347
348 // if only incentive exists for this pool,remove the entire pool entry
349 if len(incentiveIds) == 1 && incentiveIds[0] == incentiveId {
350 currentPoolIncentiveIds.Remove(poolPath)
351
352 // if no pools remain at this time, remove the time entry
353 if currentPoolIncentiveIds.Size() == 0 {
354 incentivesByTime.Remove(creationTime)
355 } else {
356 incentivesByTime.Set(creationTime, currentPoolIncentiveIds)
357 }
358
359 s.updateExternalIncentivesByCreationTime(incentivesByTime)
360
361 return
362 }
363
364 // remove only the target incentive from slice
365 for index, id := range incentiveIds {
366 if id == incentiveId {
367 incentiveIds = append(incentiveIds[:index], incentiveIds[index+1:]...)
368 break
369 }
370 }
371
372 currentPoolIncentiveIds.Set(poolPath, incentiveIds)
373 incentivesByTime.Set(creationTime, currentPoolIncentiveIds)
374
375 s.updateExternalIncentivesByCreationTime(incentivesByTime)
376}