external_incentive.gno
18.72 Kb · 527 lines
1package staker
2
3import (
4 "chain"
5 "chain/runtime"
6 "time"
7
8 "gno.land/p/gnoswap/gnsmath/v1"
9 prbac "gno.land/p/gnoswap/rbac/v1"
10 u256 "gno.land/p/gnoswap/uint256/v1"
11 "gno.land/p/gnoswap/utils/v1"
12 ufmt "gno.land/p/nt/ufmt/v0"
13
14 "gno.land/r/gnoswap/access/v1"
15 "gno.land/r/gnoswap/common"
16 en "gno.land/r/gnoswap/emission"
17 "gno.land/r/gnoswap/halt/v1"
18 sr "gno.land/r/gnoswap/staker"
19)
20
21// CreateExternalIncentive creates an external incentive program for a pool.
22//
23// Parameters:
24// - _: Noncrossing implementation-call discriminator; pass 0.
25// - rlm: Current realm context forwarded unchanged by the staker proxy.
26// - targetPoolPath: Pool path to incentivize.
27// - rewardToken: Registered reward-token path.
28// - rewardAmount: Total reward amount deposited, in reward-token units.
29// - startTimestamp: Incentive start time as Unix seconds.
30// - endTimestamp: Incentive end time as Unix seconds; it must follow startTimestamp by a valid duration.
31//
32// Any caller may create an incentive after satisfying token, duration, start-time, reward-minimum,
33// and GNS-deposit checks.
34func (s *stakerV1) CreateExternalIncentive(
35 _ int,
36 rlm realm,
37 targetPoolPath string,
38 rewardToken string, // token path should be registered
39 rewardAmount int64,
40 startTimestamp int64,
41 endTimestamp int64,
42) {
43 access.AssertIsRlmCurrent(0, rlm)
44
45 halt.AssertIsNotHaltedStaker()
46
47 prevRealm := rlm.Previous()
48 caller := prevRealm.Address()
49 currentTime := time.Now().Unix()
50
51 assertIsPoolExists(s, targetPoolPath)
52
53 assertIsGreaterThanMinimumRewardAmount(s, rewardToken, rewardAmount)
54 assertIsAllowedForExternalReward(s, targetPoolPath, rewardToken)
55 assertIsExternalRewardTokenAvailable(s, targetPoolPath, rewardToken, currentTime)
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(0, rlm, rewardToken, caller, stakerAddr, rewardAmount)
68
69 depositGnsAmount := s.store.GetDepositGnsAmount()
70
71 // deposit gns amount
72 common.SafeGRC20TransferFrom(0, rlm, GNS_TOKEN_KEY, caller, stakerAddr, depositGnsAmount)
73
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 after its end timestamp and finalizes the
125// refundable unclaimable/remainder amount.
126//
127// The reward-token refund and GNS deposit are sent to the caller-supplied refundAddress. Rewards
128// still owed by live positions remain claimable, and the incentive record is retained for that
129// accounting. Accumulated warmup penalties are collected separately with
130// CollectExternalIncentivePenalty after this function succeeds.
131//
132// Parameters:
133// - _: Noncrossing implementation-call discriminator; pass 0.
134// - rlm: Current realm context forwarded unchanged by the staker proxy.
135// - targetPoolPath: Pool containing the incentive to end.
136// - incentiveId: Unique identifier of the incentive to finalize.
137// - refundAddress: Address receiving the refundable reward amount and GNS deposit.
138//
139// Process:
140// 1. Validates that the incentive end time has been reached and no exit checkpoint still owes it.
141// 2. Calculates unclaimable/remainder rewards.
142// 3. Marks the incentive refunded and retains its record.
143// 4. Transfers the refundable rewards and deposited GNS to refundAddress.
144//
145// Only callable by the incentive creator or admin.
146func (s *stakerV1) EndExternalIncentive(_ int, rlm realm, targetPoolPath, incentiveId string, refundAddress address) {
147 access.AssertIsRlmCurrent(0, rlm)
148
149 halt.AssertIsNotHaltedWithdraw()
150
151 // checks pool registry
152 assertIsPoolExists(s, targetPoolPath)
153 assertIsValidAddress(refundAddress)
154 assertHasNoUncollectedIncentive(s, incentiveId)
155
156 // checks if the pool has been incentivized
157 pool, ok := s.getPools().Get(targetPoolPath)
158 if !ok {
159 panic(makeErrorWithDetails(
160 errDataNotFound,
161 ufmt.Sprintf("targetPoolPath(%s) not found", targetPoolPath),
162 ))
163 }
164
165 poolResolver := NewPoolResolver(pool)
166 incentivesResolver := poolResolver.IncentivesResolver()
167
168 // Get incentive to check if GNS already refunded
169 incentiveResolver, exists := incentivesResolver.GetIncentiveResolver(incentiveId)
170 if !exists {
171 panic(makeErrorWithDetails(
172 errCannotEndIncentive,
173 ufmt.Sprintf("cannot end non existent incentive(%s)", incentiveId),
174 ))
175 }
176
177 // Check if incentive has already been refunded
178 if incentiveResolver.Refunded() {
179 panic(makeErrorWithDetails(
180 errCannotEndIncentive,
181 ufmt.Sprintf("incentive(%s) has already been refunded", incentiveId),
182 ))
183 }
184
185 caller := rlm.Previous().Address()
186
187 // Process ending
188 incentive, refund, err := s.endExternalIncentive(poolResolver, incentiveResolver, caller, time.Now().Unix())
189 if err != nil {
190 panic(err)
191 }
192
193 stakerAddr := access.MustGetAddress(prbac.ROLE_STAKER.String())
194 poolLeftExternalRewardAmount := common.BalanceOf(incentiveResolver.RewardToken(), stakerAddr)
195 if poolLeftExternalRewardAmount < refund {
196 previousRealm := rlm.Previous()
197 chain.Emit(
198 "EndExternalIncentiveShortfall",
199 "prevAddr", previousRealm.Address().String(),
200 "prevRealm", previousRealm.PkgPath(),
201 "incentiveId", incentiveId,
202 "targetPoolPath", targetPoolPath,
203 "refundee", refundAddress.String(),
204 "refundToken", incentiveResolver.RewardToken(),
205 "expectedRefundAmount", utils.FormatInt(refund),
206 "actualRefundAmount", utils.FormatInt(poolLeftExternalRewardAmount),
207 "creator", incentiveResolver.Creator().String(),
208 )
209 refund = poolLeftExternalRewardAmount
210 }
211
212 // Mark incentive as refunded and update
213 // After this update, attempts to re-claim GNS or rewards that were deposited
214 // through the `endExternalIncentive` function will be blocked.
215 incentiveResolver.SetRefunded(true)
216 incentiveResolver.SetRewardAmount(gnsmath.SafeSubInt64(incentiveResolver.RewardAmount(), refund))
217 incentiveResolver.addDistributedRewardAmount(refund)
218 incentivesResolver.update(incentive)
219
220 // refund reward token to refundee
221 common.SafeGRC20Transfer(0, rlm, incentiveResolver.RewardToken(), refundAddress, refund)
222
223 // Transfer GNS deposit back to refundee
224 common.SafeGRC20Transfer(0, rlm, GNS_TOKEN_KEY, 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. Accumulated warmup penalties are tracked separately
264 // and collected through CollectExternalIncentivePenalty after the incentive is ended.
265 incentivesResolver := resolver.IncentivesResolver()
266 unclaimableReward := incentivesResolver.calculateUnclaimableReward(incentiveResolver.IncentiveId())
267 duration := gnsmath.SafeSubInt64(incentiveResolver.EndTimestamp(), incentiveResolver.StartTimestamp())
268 distributableU256 := u256.MulDiv(
269 incentiveResolver.RewardPerSecondX128(),
270 u256.NewUintFromInt64(duration),
271 q128,
272 )
273
274 distributable := gnsmath.SafeConvertToInt64(distributableU256)
275 remainder := gnsmath.SafeSubInt64(incentiveResolver.TotalRewardAmount(), distributable)
276
277 refund := gnsmath.SafeAddInt64(unclaimableReward, remainder)
278
279 maxRefund := incentiveResolver.RewardAmount()
280 if refund > maxRefund {
281 refund = maxRefund
282 }
283
284 if refund < 0 {
285 return nil, 0, makeErrorWithDetails(
286 errCalculationError,
287 ufmt.Sprintf("refund should never be negative: Got %d", refund),
288 )
289 }
290
291 return incentiveResolver.ExternalIncentive, refund, nil
292}
293
294// CancelExternalIncentive cancels an external incentive before it starts and
295// removes it entirely.
296//
297// EndExternalIncentive is the counterpart for an incentive whose end timestamp
298// has passed: it keeps the record so the penalty accounting and the deposits
299// that already collected from it stay resolvable. Cancelling is only allowed
300// strictly before startTimestamp, where nothing has accrued yet - no deposit
301// references the incentive (both StakeToken and the lazy reward discovery only
302// look at incentives whose start timestamp is already in the past), so the
303// record can be dropped from the incentive tree and the start-time index
304// instead of being marked refunded.
305//
306// Parameters:
307// - _: Noncrossing implementation-call discriminator; pass 0.
308// - rlm: Current realm context forwarded unchanged by the staker proxy.
309// - targetPoolPath: Pool path for which the incentive was created.
310// - incentiveId: Unique identifier of the incentive to cancel.
311//
312// Process:
313// 1. Validates the incentive exists and has not started.
314// 2. Removes it from the incentive tree and the start-time index.
315// 3. Refunds the available reward-token balance (capped by the staker balance) and the GNS deposit to the creator.
316//
317// Only callable by admin, governance, or the incentive creator. The permission
318// check runs after the incentive is resolved because the creator identity comes
319// from the stored record; every step before it is read-only, so no unauthorized
320// side effect can occur ahead of the check.
321func (s *stakerV1) CancelExternalIncentive(_ int, rlm realm, targetPoolPath, incentiveId string) {
322 access.AssertIsRlmCurrent(0, rlm)
323
324 halt.AssertIsNotHaltedWithdraw()
325
326 assertIsPoolExists(s, targetPoolPath)
327
328 prevRealm := rlm.Previous()
329 caller := prevRealm.Address()
330
331 pool, ok := s.getPools().Get(targetPoolPath)
332 if !ok {
333 panic(makeErrorWithDetails(
334 errDataNotFound,
335 ufmt.Sprintf("targetPoolPath(%s) not found", targetPoolPath),
336 ))
337 }
338
339 poolResolver := NewPoolResolver(pool)
340 incentivesResolver := poolResolver.IncentivesResolver()
341
342 incentiveResolver, exists := incentivesResolver.GetIncentiveResolver(incentiveId)
343 if !exists {
344 panic(makeErrorWithDetails(
345 errCannotCancelIncentive,
346 ufmt.Sprintf("cannot cancel non existent incentive(%s)", incentiveId),
347 ))
348 }
349
350 // Admin, governance, or the incentive creator may cancel it.
351 assertIsAdminGovernanceOrCreator(caller, incentiveResolver.Creator())
352
353 currentTime := time.Now().Unix()
354 assertIsNotStartedIncentive(incentiveResolver, currentTime)
355
356 rewardToken := incentiveResolver.RewardToken()
357 depositGnsAmount := incentiveResolver.DepositGnsAmount()
358 refundAmount := incentiveResolver.RewardAmount()
359
360 // The reward tokens and the GNS deposit are always returned to the incentive
361 // creator - the address that funded them in CreateExternalIncentive - so the
362 // refund destination is never caller-controlled.
363 creator := incentiveResolver.Creator()
364
365 // Cap the refund by the balance actually held, mirroring
366 // EndExternalIncentive: a short balance must not abort the cancellation and
367 // strand the GNS deposit as well.
368 stakerAddr := access.MustGetAddress(prbac.ROLE_STAKER.String())
369 rewardBalance := common.BalanceOf(rewardToken, stakerAddr)
370 if rewardBalance < refundAmount {
371 chain.Emit(
372 "CancelExternalIncentiveShortfall",
373 "prevAddr", caller.String(),
374 "prevRealm", prevRealm.PkgPath(),
375 "incentiveId", incentiveId,
376 "targetPoolPath", targetPoolPath,
377 "refundToken", rewardToken,
378 "expectedRefundAmount", utils.FormatInt(refundAmount),
379 "actualRefundAmount", utils.FormatInt(rewardBalance),
380 "refundee", creator.String(),
381 "creator", creator.String(),
382 )
383 refundAmount = rewardBalance
384 }
385
386 // Effects before interactions: once removed, no stake, collect or discovery
387 // path can resolve the incentive, so the refund cannot be replayed.
388 incentivesResolver.remove(incentiveResolver.ExternalIncentive)
389 s.getExternalIncentives().remove(incentiveId)
390
391 if refundAmount > 0 {
392 // transfer reward token back to creator
393 common.SafeGRC20Transfer(0, rlm, rewardToken, creator, refundAmount)
394 }
395
396 if depositGnsAmount > 0 {
397 // transfer GNS deposit back to creator
398 common.SafeGRC20Transfer(0, rlm, GNS_TOKEN_KEY, creator, depositGnsAmount)
399 }
400
401 chain.Emit(
402 "CancelExternalIncentive",
403 "prevAddr", caller.String(),
404 "prevRealm", prevRealm.PkgPath(),
405 "incentiveId", incentiveId,
406 "targetPoolPath", targetPoolPath,
407 "refundToken", rewardToken,
408 "refundAmount", utils.FormatInt(refundAmount),
409 "refundGnsAmount", utils.FormatInt(depositGnsAmount),
410 "startTimestamp", utils.FormatInt(incentiveResolver.StartTimestamp()),
411 "endTimestamp", utils.FormatInt(incentiveResolver.EndTimestamp()),
412 "externalIncentiveCancelBy", caller.String(),
413 "creator", creator.String(),
414 "currentTime", utils.FormatInt(currentTime),
415 )
416}
417
418// CollectExternalIncentivePenalty collects accumulated warmup penalties for a specific ended
419// external incentive. The incentive must first be finalized with EndExternalIncentive.
420// Penalties are accumulated during CollectReward and stored in the incentive.
421// This function transfers the accumulated penalty to the specified refund address.
422//
423// Parameters:
424// - _: Noncrossing implementation-call discriminator; pass 0.
425// - rlm: Current realm context forwarded unchanged by the staker proxy.
426// - targetPoolPath: Pool containing the ended incentive.
427// - incentiveId: Unique identifier of the ended incentive whose penalty is collected.
428// - refundAddress: Address receiving the transferred penalty amount.
429//
430// Returns:
431// - penaltyAmount: Accumulated warmup penalty transferred, capped by the staker's available reward-token balance.
432//
433// Only callable by the incentive creator or admin.
434func (s *stakerV1) CollectExternalIncentivePenalty(
435 _ int,
436 rlm realm,
437 targetPoolPath string,
438 incentiveId string,
439 refundAddress address,
440) int64 {
441 access.AssertIsRlmCurrent(0, rlm)
442
443 halt.AssertIsNotHaltedWithdraw()
444
445 assertIsPoolExists(s, targetPoolPath)
446 assertIsValidAddress(refundAddress)
447
448 pool, ok := s.getPools().Get(targetPoolPath)
449 if !ok {
450 panic(makeErrorWithDetails(
451 errDataNotFound,
452 ufmt.Sprintf("targetPoolPath(%s) not found", targetPoolPath),
453 ))
454 }
455
456 poolResolver := NewPoolResolver(pool)
457 incentivesResolver := poolResolver.IncentivesResolver()
458
459 incentiveResolver, exists := incentivesResolver.GetIncentiveResolver(incentiveId)
460 if !exists {
461 panic(makeErrorWithDetails(
462 errDataNotFound,
463 ufmt.Sprintf("incentive(%s) not found", incentiveId),
464 ))
465 }
466
467 if !incentiveResolver.Refunded() {
468 panic(makeErrorWithDetails(
469 errIsNotEndedIncentive,
470 ufmt.Sprintf("incentive(%s) must be ended first (call EndExternalIncentive)", incentiveId),
471 ))
472 }
473
474 caller := rlm.Previous().Address()
475 if !access.IsAuthorized(prbac.ROLE_ADMIN.String(), caller) && caller != incentiveResolver.Creator() {
476 adminAddr := access.MustGetAddress(prbac.ROLE_ADMIN.String())
477 panic(makeErrorWithDetails(
478 errNoPermission,
479 ufmt.Sprintf("only creator(%s) or admin(%s) can collect penalty, but called from %s", incentiveResolver.Creator(), adminAddr.String(), caller),
480 ))
481 }
482
483 penaltyAmount := incentiveResolver.AccumulatedPenaltyAmount()
484 if penaltyAmount == 0 {
485 return 0
486 }
487
488 // Cap by actual staker balance
489 stakerAddr := access.MustGetAddress(prbac.ROLE_STAKER.String())
490 balance := common.BalanceOf(incentiveResolver.RewardToken(), stakerAddr)
491 if balance < penaltyAmount {
492 previousRealm := rlm.Previous()
493 chain.Emit(
494 "CollectExternalIncentivePenaltyShortfall",
495 "prevAddr", previousRealm.Address().String(),
496 "prevRealm", previousRealm.PkgPath(),
497 "targetPoolPath", targetPoolPath,
498 "incentiveId", incentiveId,
499 "refundAddress", refundAddress.String(),
500 "refundToken", incentiveResolver.RewardToken(),
501 "expectedPenaltyAmount", utils.FormatInt(penaltyAmount),
502 "actualPenaltyAmount", utils.FormatInt(balance),
503 "creator", incentiveResolver.Creator().String(),
504 )
505 penaltyAmount = balance
506 }
507
508 // Reset accumulated penalty
509 incentiveResolver.SetAccumulatedPenaltyAmount(gnsmath.SafeSubInt64(incentiveResolver.AccumulatedPenaltyAmount(), penaltyAmount))
510 incentivesResolver.update(incentiveResolver.ExternalIncentive)
511
512 // Transfer penalty to refund address
513 common.SafeGRC20Transfer(0, rlm, incentiveResolver.RewardToken(), refundAddress, penaltyAmount)
514
515 previousRealm := rlm.Previous()
516 chain.Emit(
517 "CollectExternalIncentivePenalty",
518 "prevAddr", previousRealm.Address().String(),
519 "prevRealm", previousRealm.PkgPath(),
520 "targetPoolPath", targetPoolPath,
521 "incentiveId", incentiveId,
522 "refundAddress", refundAddress.String(),
523 "penaltyAmount", utils.FormatInt(penaltyAmount),
524 )
525
526 return penaltyAmount
527}