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

12.60 Kb · 397 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	ufmt "gno.land/p/nt/ufmt/v0"
 14
 15	"gno.land/r/gnoswap/access"
 16	"gno.land/r/gnoswap/common"
 17	en "gno.land/r/gnoswap/emission"
 18	"gno.land/r/gnoswap/gns"
 19	"gno.land/r/gnoswap/halt"
 20	sr "gno.land/r/gnoswap/staker"
 21)
 22
 23// CreateExternalIncentive creates an external incentive program for a pool.
 24//
 25// Parameters:
 26//   - targetPoolPath: pool to incentivize
 27//   - rewardToken: reward token path
 28//   - rewardAmount: total reward amount
 29//   - startTimestamp, endTimestamp: incentive period
 30//
 31// Only callable by admin.
 32func (s *stakerV1) CreateExternalIncentive(
 33	_ int,
 34	rlm realm,
 35	targetPoolPath string,
 36	rewardToken string, // token path should be registered
 37	rewardAmount int64,
 38	startTimestamp int64,
 39	endTimestamp int64,
 40) {
 41	if !rlm.IsCurrent() {
 42		panic(errors.New(errSpoofedRealm))
 43	}
 44
 45	halt.AssertIsNotHaltedStaker()
 46
 47	prevRealm := rlm.Previous()
 48	caller := prevRealm.Address()
 49	access.AssertIsAdmin(caller)
 50
 51	assertIsPoolExists(s, targetPoolPath)
 52
 53	assertIsGreaterThanMinimumRewardAmount(s, rewardToken, rewardAmount)
 54	assertIsAllowedForExternalReward(s, targetPoolPath, rewardToken)
 55	assertIsValidIncentiveStartTime(startTimestamp)
 56	assertIsValidIncentiveEndTime(endTimestamp)
 57	assertIsValidIncentiveDuration(gnsmath.SafeSubInt64(endTimestamp, startTimestamp))
 58	// assert that the user has sent the correct amount of native coin
 59	common.AssertIsNotHandleNativeCoin()
 60
 61	en.MintAndDistributeGns(cross(rlm))
 62
 63	stakerAddr := access.MustGetAddress(prbac.ROLE_STAKER.String())
 64
 65	// transfer reward token from user to staker
 66	common.SafeGRC20TransferFrom(cross(rlm), rewardToken, caller, stakerAddr, rewardAmount)
 67
 68	depositGnsAmount := s.store.GetDepositGnsAmount()
 69
 70	// deposit gns amount
 71	gns.TransferFrom(cross(rlm), caller, stakerAddr, depositGnsAmount)
 72
 73	currentTime := time.Now().Unix()
 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 and refunds remaining rewards.
125//
126// Finalizes incentive program after end timestamp.
127// Returns unallocated rewards and GNS deposit.
128// Calculates unclaimable rewards for refund.
129//
130// Parameters:
131//   - targetPoolPath: Pool with the incentive
132//   - incentiveId: Unique incentive identifier
133//
134// Process:
135//  1. Validates incentive end time reached
136//  2. Calculates remaining and unclaimable rewards
137//  3. Refunds rewards to original creator
138//  4. Returns 100 GNS deposit
139//  5. Removes incentive from active list
140//
141// Only callable by Creator or Admin.
142func (s *stakerV1) EndExternalIncentive(_ int, rlm realm, targetPoolPath, incentiveId string, refundAddress address) {
143	if !rlm.IsCurrent() {
144		panic(errors.New(errSpoofedRealm))
145	}
146
147	halt.AssertIsNotHaltedWithdraw()
148
149	// checks pool registry
150	assertIsPoolExists(s, targetPoolPath)
151	assertIsValidAddress(refundAddress)
152
153	// checks if the pool has been incentivized
154	pool, ok := s.getPools().Get(targetPoolPath)
155	if !ok {
156		panic(makeErrorWithDetails(
157			errDataNotFound,
158			ufmt.Sprintf("targetPoolPath(%s) not found", targetPoolPath),
159		))
160	}
161
162	poolResolver := NewPoolResolver(pool)
163	incentivesResolver := poolResolver.IncentivesResolver()
164
165	// Get incentive to check if GNS already refunded
166	incentiveResolver, exists := incentivesResolver.GetIncentiveResolver(incentiveId)
167	if !exists {
168		panic(makeErrorWithDetails(
169			errCannotEndIncentive,
170			ufmt.Sprintf("cannot end non existent incentive(%s)", incentiveId),
171		))
172	}
173
174	// Check if incentive has already been refunded
175	if incentiveResolver.Refunded() {
176		panic(makeErrorWithDetails(
177			errCannotEndIncentive,
178			ufmt.Sprintf("incentive(%s) has already been refunded", incentiveId),
179		))
180	}
181
182	caller := rlm.Previous().Address()
183
184	// Process ending
185	incentive, refund, err := s.endExternalIncentive(poolResolver, incentiveResolver, caller, time.Now().Unix())
186	if err != nil {
187		panic(err)
188	}
189
190	stakerAddr := access.MustGetAddress(prbac.ROLE_STAKER.String())
191	poolLeftExternalRewardAmount := common.BalanceOf(incentiveResolver.RewardToken(), stakerAddr)
192	if poolLeftExternalRewardAmount < refund {
193		previousRealm := rlm.Previous()
194		chain.Emit(
195			"EndExternalIncentiveShortfall",
196			"prevAddr", previousRealm.Address().String(),
197			"prevRealm", previousRealm.PkgPath(),
198			"incentiveId", incentiveId,
199			"targetPoolPath", targetPoolPath,
200			"refundee", refundAddress.String(),
201			"refundToken", incentiveResolver.RewardToken(),
202			"expectedRefundAmount", utils.FormatInt(refund),
203			"actualRefundAmount", utils.FormatInt(poolLeftExternalRewardAmount),
204			"creator", incentiveResolver.Creator().String(),
205		)
206		refund = poolLeftExternalRewardAmount
207	}
208
209	// Mark incentive as refunded and update
210	// After this update, attempts to re-claim GNS or rewards that were deposited
211	// through the `endExternalIncentive` function will be blocked.
212	incentiveResolver.SetRefunded(true)
213	incentiveResolver.SetRewardAmount(gnsmath.SafeSubInt64(incentiveResolver.RewardAmount(), refund))
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", utils.FormatInt(refund),
233		"refundGnsAmount", utils.FormatInt(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 := gnsmath.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 := gnsmath.SafeConvertToInt64(distributableU256)
276	remainder := gnsmath.SafeSubInt64(incentiveResolver.TotalRewardAmount(), distributable)
277
278	refund := gnsmath.SafeAddInt64(unclaimableReward, remainder)
279
280	maxRefund := incentiveResolver.RewardAmount()
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(errors.New(errSpoofedRealm))
311	}
312
313	halt.AssertIsNotHaltedWithdraw()
314
315	assertIsPoolExists(s, targetPoolPath)
316	assertIsValidAddress(refundAddress)
317
318	pool, ok := s.getPools().Get(targetPoolPath)
319	if !ok {
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", utils.FormatInt(penaltyAmount),
372			"actualPenaltyAmount", utils.FormatInt(balance),
373			"creator", incentiveResolver.Creator().String(),
374		)
375		penaltyAmount = balance
376	}
377
378	// Reset accumulated penalty
379	incentiveResolver.SetAccumulatedPenaltyAmount(gnsmath.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", utils.FormatInt(penaltyAmount),
394	)
395
396	return penaltyAmount
397}