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

14.70 Kb · 450 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	bptree "gno.land/p/nt/bptree/v0"
 14	ufmt "gno.land/p/nt/ufmt/v0"
 15
 16	"gno.land/r/gnoswap/access"
 17	"gno.land/r/gnoswap/common"
 18	en "gno.land/r/gnoswap/emission"
 19	"gno.land/r/gnoswap/gns"
 20	"gno.land/r/gnoswap/halt"
 21	sr "gno.land/r/gnoswap/staker"
 22)
 23
 24// CreateExternalIncentive creates an external incentive program for a pool.
 25//
 26// Parameters:
 27//   - targetPoolPath: pool to incentivize
 28//   - rewardToken: reward token path
 29//   - rewardAmount: total reward amount
 30//   - startTimestamp, endTimestamp: incentive period
 31//
 32// Only callable by admin.
 33func (s *stakerV1) CreateExternalIncentive(
 34	_ int,
 35	rlm realm,
 36	targetPoolPath string,
 37	rewardToken string, // token path should be registered
 38	rewardAmount int64,
 39	startTimestamp int64,
 40	endTimestamp int64,
 41) {
 42	if !rlm.IsCurrent() {
 43		panic(errors.New(errSpoofedRealm))
 44	}
 45
 46	halt.AssertIsNotHaltedStaker()
 47
 48	prevRealm := rlm.Previous()
 49	caller := prevRealm.Address()
 50	access.AssertIsAdmin(caller)
 51
 52	assertIsPoolExists(s, targetPoolPath)
 53
 54	assertIsGreaterThanMinimumRewardAmount(s, rewardToken, rewardAmount)
 55	assertIsAllowedForExternalReward(s, targetPoolPath, rewardToken)
 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(cross(rlm), rewardToken, caller, stakerAddr, rewardAmount)
 68
 69	depositGnsAmount := s.store.GetDepositGnsAmount()
 70
 71	// deposit gns amount
 72	gns.TransferFrom(cross(rlm), 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	)
 95
 96	externalIncentives := s.store.GetExternalIncentives()
 97	if externalIncentives.Has(incentiveId) {
 98		panic(makeErrorWithDetails(
 99			errIncentiveAlreadyExists,
100			ufmt.Sprintf("incentiveId(%s)", incentiveId),
101		))
102	}
103	// store external incentive information for each incentiveId
104	externalIncentives.Set(incentiveId, incentive)
105
106	poolResolver := NewPoolResolver(pool)
107	poolResolver.IncentivesResolver().create(incentive)
108
109	// add incentive to time-based index for lazy discovery during CollectReward
110	s.addIncentiveIdByCreationTime(0, rlm, targetPoolPath, incentiveId, currentTime)
111
112	chain.Emit(
113		"CreateExternalIncentive",
114		"prevAddr", caller.String(),
115		"prevRealm", prevRealm.PkgPath(),
116		"incentiveId", incentiveId,
117		"targetPoolPath", targetPoolPath,
118		"rewardToken", rewardToken,
119		"rewardAmount", utils.FormatInt(rewardAmount),
120		"startTimestamp", utils.FormatInt(startTimestamp),
121		"endTimestamp", utils.FormatInt(endTimestamp),
122		"depositGnsAmount", utils.FormatInt(depositGnsAmount),
123		"currentHeight", utils.FormatInt(currentHeight),
124		"currentTime", utils.FormatInt(currentTime),
125	)
126}
127
128// EndExternalIncentive ends an external incentive and refunds remaining rewards.
129//
130// Finalizes incentive program after end timestamp.
131// Returns unallocated rewards and GNS deposit.
132// Calculates unclaimable rewards for refund.
133//
134// Parameters:
135//   - targetPoolPath: Pool with the incentive
136//   - incentiveId: Unique incentive identifier
137//
138// Process:
139//  1. Validates incentive end time reached
140//  2. Calculates remaining and unclaimable rewards
141//  3. Refunds rewards to original creator
142//  4. Returns 100 GNS deposit
143//  5. Removes incentive from active list
144//
145// Only callable by Creator or Admin.
146func (s *stakerV1) EndExternalIncentive(_ int, rlm realm, targetPoolPath, incentiveId string, refundAddress address) {
147	if !rlm.IsCurrent() {
148		panic(errors.New(errSpoofedRealm))
149	}
150
151	halt.AssertIsNotHaltedWithdraw()
152
153	// checks pool registry
154	assertIsPoolExists(s, targetPoolPath)
155	assertIsValidAddress(refundAddress)
156
157	// checks if the pool has been incentivized
158	pool, ok := s.getPools().Get(targetPoolPath)
159	if !ok {
160		panic(makeErrorWithDetails(
161			errDataNotFound,
162			ufmt.Sprintf("targetPoolPath(%s) not found", targetPoolPath),
163		))
164	}
165
166	poolResolver := NewPoolResolver(pool)
167	incentivesResolver := poolResolver.IncentivesResolver()
168
169	// Get incentive to check if GNS already refunded
170	incentiveResolver, exists := incentivesResolver.GetIncentiveResolver(incentiveId)
171	if !exists {
172		panic(makeErrorWithDetails(
173			errCannotEndIncentive,
174			ufmt.Sprintf("cannot end non existent incentive(%s)", incentiveId),
175		))
176	}
177
178	// Check if incentive has already been refunded
179	if incentiveResolver.Refunded() {
180		panic(makeErrorWithDetails(
181			errCannotEndIncentive,
182			ufmt.Sprintf("incentive(%s) has already been refunded", incentiveId),
183		))
184	}
185
186	caller := rlm.Previous().Address()
187
188	// Process ending
189	incentive, refund, err := s.endExternalIncentive(poolResolver, incentiveResolver, caller, time.Now().Unix())
190	if err != nil {
191		panic(err)
192	}
193
194	stakerAddr := access.MustGetAddress(prbac.ROLE_STAKER.String())
195	poolLeftExternalRewardAmount := common.BalanceOf(incentiveResolver.RewardToken(), stakerAddr)
196	if poolLeftExternalRewardAmount < refund {
197		previousRealm := rlm.Previous()
198		chain.Emit(
199			"EndExternalIncentiveShortfall",
200			"prevAddr", previousRealm.Address().String(),
201			"prevRealm", previousRealm.PkgPath(),
202			"incentiveId", incentiveId,
203			"targetPoolPath", targetPoolPath,
204			"refundee", refundAddress.String(),
205			"refundToken", incentiveResolver.RewardToken(),
206			"expectedRefundAmount", utils.FormatInt(refund),
207			"actualRefundAmount", utils.FormatInt(poolLeftExternalRewardAmount),
208			"creator", incentiveResolver.Creator().String(),
209		)
210		refund = poolLeftExternalRewardAmount
211	}
212
213	// Mark incentive as refunded and update
214	// After this update, attempts to re-claim GNS or rewards that were deposited
215	// through the `endExternalIncentive` function will be blocked.
216	incentiveResolver.SetRefunded(true)
217	incentiveResolver.addDistributedRewardAmount(refund)
218	incentivesResolver.update(incentive)
219
220	// refund reward token to refundee
221	common.SafeGRC20Transfer(cross(rlm), incentiveResolver.RewardToken(), refundAddress, refund)
222
223	// Transfer GNS deposit back to refundee
224	gns.Transfer(cross(rlm), 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 + accumulatedPenaltyAmount
264	incentivesResolver := resolver.IncentivesResolver()
265	unclaimableReward := incentivesResolver.calculateUnclaimableReward(incentiveResolver.IncentiveId())
266
267	duration := gnsmath.SafeSubInt64(incentiveResolver.EndTimestamp(), incentiveResolver.StartTimestamp())
268	// distributable = floor((rewardPerSecondX128 * duration) / 2^128).
269	// With Q128 scaling the truncation per second collapses to at most 1 wei
270	// across the entire duration, so `remainder` is effectively zero and the
271	// refund accounts only for unclaimable periods.
272	distributableU256 := u256.MulDiv(
273		incentiveResolver.RewardPerSecondX128(),
274		u256.NewUintFromInt64(duration),
275		q128,
276	)
277
278	distributable := gnsmath.SafeConvertToInt64(distributableU256)
279	remainder := gnsmath.SafeSubInt64(incentiveResolver.TotalRewardAmount(), distributable)
280
281	refund := gnsmath.SafeAddInt64(unclaimableReward, remainder)
282
283	maxRefund := gnsmath.SafeSubInt64(incentiveResolver.TotalRewardAmount(), incentiveResolver.DistributedRewardAmount())
284	if refund > maxRefund {
285		refund = maxRefund
286	}
287
288	if refund < 0 {
289		return nil, 0, makeErrorWithDetails(
290			errCalculationError,
291			ufmt.Sprintf("refund should never be negative: Got %d", refund),
292		)
293	}
294
295	return incentiveResolver.ExternalIncentive, refund, nil
296}
297
298// CollectExternalIncentivePenalty collects accumulated warmup penalties
299// for a specific ended external incentive.
300// Penalties are accumulated during CollectReward and stored in the incentive.
301// This function transfers the accumulated penalty to the specified refund address.
302// Returns the penalty amount collected.
303//
304// Only callable by the incentive creator or admin.
305func (s *stakerV1) CollectExternalIncentivePenalty(
306	_ int,
307	rlm realm,
308	targetPoolPath string,
309	incentiveId string,
310	refundAddress address,
311) int64 {
312	if !rlm.IsCurrent() {
313		panic(errors.New(errSpoofedRealm))
314	}
315
316	halt.AssertIsNotHaltedWithdraw()
317
318	assertIsPoolExists(s, targetPoolPath)
319	assertIsValidAddress(refundAddress)
320
321	pool, ok := s.getPools().Get(targetPoolPath)
322	if !ok {
323		panic(makeErrorWithDetails(
324			errDataNotFound,
325			ufmt.Sprintf("targetPoolPath(%s) not found", targetPoolPath),
326		))
327	}
328
329	poolResolver := NewPoolResolver(pool)
330	incentivesResolver := poolResolver.IncentivesResolver()
331
332	incentiveResolver, exists := incentivesResolver.GetIncentiveResolver(incentiveId)
333	if !exists {
334		panic(makeErrorWithDetails(
335			errDataNotFound,
336			ufmt.Sprintf("incentive(%s) not found", incentiveId),
337		))
338	}
339
340	if !incentiveResolver.Refunded() {
341		panic(makeErrorWithDetails(
342			errIsNotEndedIncentive,
343			ufmt.Sprintf("incentive(%s) must be ended first (call EndExternalIncentive)", incentiveId),
344		))
345	}
346
347	caller := rlm.Previous().Address()
348	if !access.IsAuthorized(prbac.ROLE_ADMIN.String(), caller) && caller != incentiveResolver.Creator() {
349		adminAddr := access.MustGetAddress(prbac.ROLE_ADMIN.String())
350		panic(makeErrorWithDetails(
351			errNoPermission,
352			ufmt.Sprintf("only creator(%s) or admin(%s) can collect penalty, but called from %s", incentiveResolver.Creator(), adminAddr.String(), caller),
353		))
354	}
355
356	penaltyAmount := incentiveResolver.AccumulatedPenaltyAmount()
357	if penaltyAmount == 0 {
358		return 0
359	}
360
361	// Cap by actual staker balance
362	stakerAddr := access.MustGetAddress(prbac.ROLE_STAKER.String())
363	balance := common.BalanceOf(incentiveResolver.RewardToken(), stakerAddr)
364	if balance < penaltyAmount {
365		previousRealm := rlm.Previous()
366		chain.Emit(
367			"CollectExternalIncentivePenaltyShortfall",
368			"prevAddr", previousRealm.Address().String(),
369			"prevRealm", previousRealm.PkgPath(),
370			"targetPoolPath", targetPoolPath,
371			"incentiveId", incentiveId,
372			"refundAddress", refundAddress.String(),
373			"refundToken", incentiveResolver.RewardToken(),
374			"expectedPenaltyAmount", utils.FormatInt(penaltyAmount),
375			"actualPenaltyAmount", utils.FormatInt(balance),
376			"creator", incentiveResolver.Creator().String(),
377		)
378		penaltyAmount = balance
379	}
380
381	// Reset accumulated penalty
382	incentiveResolver.SetAccumulatedPenaltyAmount(gnsmath.SafeSubInt64(incentiveResolver.AccumulatedPenaltyAmount(), penaltyAmount))
383	incentivesResolver.update(incentiveResolver.ExternalIncentive)
384
385	// Transfer penalty to refund address
386	common.SafeGRC20Transfer(cross(rlm), incentiveResolver.RewardToken(), refundAddress, penaltyAmount)
387
388	previousRealm := rlm.Previous()
389	chain.Emit(
390		"CollectExternalIncentivePenalty",
391		"prevAddr", previousRealm.Address().String(),
392		"prevRealm", previousRealm.PkgPath(),
393		"targetPoolPath", targetPoolPath,
394		"incentiveId", incentiveId,
395		"refundAddress", refundAddress.String(),
396		"penaltyAmount", utils.FormatInt(penaltyAmount),
397	)
398
399	return penaltyAmount
400}
401
402// addIncentiveIdByCreationTime adds an external incentive to the time-based index.
403//
404// The index structure is:
405//   - creationTime (int64) -> poolPath (string) -> []incentiveIds
406func (s *stakerV1) addIncentiveIdByCreationTime(_ int, rlm realm, poolPath, incentiveId string, creationTime int64) {
407	incentivesByTime := s.getExternalIncentivesByCreationTime()
408
409	// Rebuild the nested poolPath -> []incentiveId tree into a freshly allocated
410	// bptree on every call instead of mutating the one fetched out of the
411	// persisted UintTree. A persisted nested tree is owned by /r/gnoswap/staker
412	// and a direct bptree.Set on it from a different calling realm trips the
413	// readonly-taint gate (the bptree Set runs in the caller's realm, not the
414	// tree's owning realm). Allocating a fresh tree here makes it owned by the
415	// executing realm, so its leaf writes are always permitted; re-Setting all
416	// existing entries preserves the stored shape.
417	freshPoolIncentiveIds := sr.NewBPTreeN(16)
418	found := false
419
420	if currentPoolIncentiveIdsValue, ok := incentivesByTime.Get(creationTime); ok {
421		currentPoolIncentiveIds, ok := currentPoolIncentiveIdsValue.(*bptree.BPTree)
422		if !ok {
423			panic(ufmt.Sprintf("invalid type in incentivesByTime tree: expected *bptree.BPTree, got %T", currentPoolIncentiveIdsValue))
424		}
425
426		currentPoolIncentiveIds.Iterate("", "", func(key string, value any) bool {
427			incentiveIds, ok := value.([]string)
428			if !ok {
429				panic(ufmt.Sprintf("invalid type in incentivesByTime tree: expected []string, got %T", value))
430			}
431
432			copied := make([]string, len(incentiveIds))
433			copy(copied, incentiveIds)
434			if key == poolPath {
435				copied = append(copied, incentiveId)
436				found = true
437			}
438			freshPoolIncentiveIds.Set(key, copied)
439			return false
440		})
441	}
442
443	if !found {
444		freshPoolIncentiveIds.Set(poolPath, []string{incentiveId})
445	}
446
447	incentivesByTime.Set(creationTime, freshPoolIncentiveIds)
448
449	s.updateExternalIncentivesByCreationTime(0, rlm, incentivesByTime)
450}