Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}