Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}