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

assert.gno

13.08 Kb · 404 lines
  1package staker
  2
  3import (
  4	"math"
  5	"strconv"
  6	"strings"
  7	"time"
  8
  9	prbac "gno.land/p/gnoswap/rbac/v1"
 10	ufmt "gno.land/p/nt/ufmt/v0"
 11
 12	"gno.land/r/gnoswap/access/v1"
 13)
 14
 15const (
 16	TIMESTAMP_90DAYS  = int64(7776000)
 17	TIMESTAMP_180DAYS = int64(15552000)
 18	TIMESTAMP_365DAYS = int64(31536000)
 19
 20	maxIncentiveStartDelay = 7 * 24 * time.Hour
 21
 22	MAX_UNIX_EPOCH_TIME = 253402300799 // 9999-12-31 23:59:59
 23
 24	maxUnstakingFee = uint64(1000) // 10%
 25)
 26
 27// assertIsValidAmount ensures the amount is non-negative.
 28func assertIsValidAmount(amount int64) {
 29	if amount < 0 {
 30		panic(makeErrorWithDetails(
 31			errInvalidInput,
 32			ufmt.Sprintf("amount(%d) must be positive", amount),
 33		))
 34	}
 35}
 36
 37// assertIsValidRewardAmountFormat ensures the reward amount string is formatted as "tokenPath:amount".
 38func assertIsValidRewardAmountFormat(rewardAmountStr string) {
 39	parts := strings.SplitN(rewardAmountStr, ":", 2)
 40	if len(parts) != 2 {
 41		panic(makeErrorWithDetails(
 42			errInvalidInput,
 43			ufmt.Sprintf("invalid format for SetTokenMinimumRewardAmount params: expected 'tokenPath:amount', got '%s'", rewardAmountStr),
 44		))
 45	}
 46}
 47
 48// assertIsDepositor ensures the caller is the owner of the deposit.
 49func assertIsDepositor(s *stakerV1, caller address, positionId uint64) {
 50	deposit := s.getDeposits().get(positionId)
 51	if deposit == nil {
 52		panic(makeErrorWithDetails(
 53			errDataNotFound,
 54			ufmt.Sprintf("positionId(%d) not found", positionId),
 55		))
 56	}
 57
 58	if caller != deposit.Owner() {
 59		panic(makeErrorWithDetails(
 60			errNoPermission,
 61			ufmt.Sprintf("caller(%s) is not depositor(%s)", caller.String(), deposit.Owner().String()),
 62		))
 63	}
 64}
 65
 66// assertIsIncentiveOfDeposit ensures the incentive exists and targets the pool the position is staked
 67// in, so a caller cannot ask a deposit to collect an incentive that can never pay it.
 68// assertIsIncentiveOfPool ensures the incentive targets the pool the position is staked in.
 69func assertIsIncentiveOfPool(s *stakerV1, poolPath string, positionId uint64, incentiveId string) {
 70	// get panics with errDataNotFound when the incentive does not exist.
 71	incentive := s.getExternalIncentives().get(incentiveId)
 72
 73	if incentive.TargetPoolPath() != poolPath {
 74		panic(makeErrorWithDetails(
 75			errInvalidInput,
 76			ufmt.Sprintf(
 77				"incentiveId(%s) targets pool(%s), but positionId(%d) is staked in pool(%s)",
 78				incentiveId, incentive.TargetPoolPath(), positionId, poolPath,
 79			),
 80		))
 81	}
 82}
 83
 84// assertHasNoExitCheckpoint ensures the position carries no reward from a previous stake.
 85// Collecting it here would smuggle an unbounded calculation into staking, and leaving it would
 86// let the next unstake overwrite the checkpoint.
 87func assertHasNoExitCheckpoint(s *stakerV1, positionId uint64) {
 88	if s.HasUnstakedPosition(positionId) {
 89		panic(makeErrorWithDetails(
 90			errUncollectedPosition,
 91			ufmt.Sprintf("positionId(%d) cannot be staked while its exit checkpoint owes rewards; collect after emission resumes if emission is halted", positionId),
 92		))
 93	}
 94}
 95
 96// assertHasNoUncollectedIncentive ensures no unstaked position still owes a reward from this
 97// incentive, which has not been drawn down from it yet.
 98func assertHasNoUncollectedIncentive(s *stakerV1, incentiveId string) {
 99	count := s.uncollectedIncentiveCountOf(incentiveId)
100	if count > 0 {
101		panic(makeErrorWithDetails(
102			errUncollectedPosition,
103			ufmt.Sprintf("incentive(%s) is owed to %d unstaked position(s)", incentiveId, count),
104		))
105	}
106}
107
108// assertIsNotStaked ensures the position is not already staked.
109func assertIsNotStaked(s *stakerV1, positionId uint64) {
110	if s.getDeposits().Has(positionId) {
111		panic(makeErrorWithDetails(
112			errAlreadyStaked,
113			ufmt.Sprintf("positionId(%d) already staked", positionId),
114		))
115	}
116}
117
118// assertIsPoolExists ensures the pool exists.
119func assertIsPoolExists(s *stakerV1, poolPath string) {
120	if !s.poolAccessor.ExistsPoolPath(poolPath) {
121		panic(makeErrorWithDetails(
122			errInvalidPoolPath,
123			ufmt.Sprintf("pool(%s) does not exist", poolPath),
124		))
125	}
126}
127
128// assertIsValidPoolTier ensures the tier is within valid range.
129func assertIsValidPoolTier(tier uint64) {
130	if tier >= AllTierCount {
131		panic(makeErrorWithDetails(
132			errInvalidPoolTier,
133			ufmt.Sprintf("tier(%d) must be less than %d", tier, AllTierCount),
134		))
135	}
136}
137
138// assertTier1HasSparePool ensures tier 1 keeps at least one pool after a tier change.
139func assertTier1HasSparePool(currentTier, tier1Count uint64) {
140	if currentTier == Tier1 && tier1Count == 1 {
141		panic(makeErrorWithDetails(errInvalidPoolTier, "tier 1 must have at least one pool"))
142	}
143}
144
145// assertIsGreaterThanMinimumRewardAmount ensures the reward amount meets minimum requirements.
146func assertIsGreaterThanMinimumRewardAmount(s *stakerV1, rewardToken string, rewardAmount int64) {
147	minReward := s.getMinimumRewardAmount()
148
149	if minRewardInt64, found := s.store.GetTokenSpecificMinimumRewards()[rewardToken]; found {
150		minReward = minRewardInt64
151	}
152
153	if rewardAmount < minReward {
154		panic(makeErrorWithDetails(
155			errInvalidInput,
156			ufmt.Sprintf("rewardAmount(%d) is less than minimum required amount(%d)", rewardAmount, minReward),
157		))
158	}
159}
160
161// assertIsAllowedForExternalReward ensures the token is allowed for external rewards.
162func assertIsAllowedForExternalReward(s *stakerV1, poolPath, tokenPath string) {
163	// Operational stop switch: a denied token can never start a NEW incentive,
164	// whether it qualifies as a pool-pair token or through the governance
165	// allowlist. Collection of already-created incentives is deliberately
166	// unaffected; the delivery guard in deliverExternalIncentiveReward bounds
167	// that blast radius instead.
168	if contains(s.store.GetDeniedRewardTokens(), tokenPath) {
169		panic(makeErrorWithDetails(
170			errDeniedRewardToken,
171			ufmt.Sprintf("tokenPath(%s) is denied as an external reward token", tokenPath),
172		))
173	}
174
175	token0, token1, _ := poolPathDivide(poolPath)
176
177	if tokenPath == token0 || tokenPath == token1 {
178		return
179	}
180
181	allowed := contains(s.store.GetAllowedTokens(), tokenPath)
182	if allowed {
183		return
184	}
185
186	panic(makeErrorWithDetails(
187		errNotAllowedForExternalReward,
188		ufmt.Sprintf("tokenPath(%s) is not allowed for external reward for poolPath(%s)", tokenPath, poolPath),
189	))
190}
191
192// assertIsExternalRewardTokenAvailable ensures a pool has no unfinished incentive for the token.
193func assertIsExternalRewardTokenAvailable(s *stakerV1, poolPath, tokenPath string, currentTime int64) {
194	pool := s.getPools().GetPoolOrNil(poolPath)
195	if pool == nil || pool.Incentives() == nil {
196		return
197	}
198
199	minimumActiveIncentiveStartTime := currentTime - TIMESTAMP_365DAYS
200	if minimumActiveIncentiveStartTime < 0 {
201		minimumActiveIncentiveStartTime = 0
202	}
203
204	pool.Incentives().IterateIncentiveIdsByTime(minimumActiveIncentiveStartTime, math.MaxInt64, func(incentiveID string) bool {
205		incentive := s.getExternalIncentives().get(incentiveID)
206		if incentive == nil {
207			return false
208		}
209
210		if incentive.RewardToken() != tokenPath || NewExternalIncentiveResolver(incentive).IsEnded(currentTime) {
211			return false
212		}
213
214		panic(makeErrorWithDetails(
215			errIncentiveAlreadyExists,
216			ufmt.Sprintf(
217				"rewardToken(%s) already has unfinished incentive(%s) for poolPath(%s)",
218				tokenPath,
219				incentiveID,
220				poolPath,
221			),
222		))
223	})
224}
225
226// assertIsValidFeeRate ensures the fee rate is within valid range (0-1000 basis points).
227func assertIsValidFeeRate(fee uint64) {
228	if fee > maxUnstakingFee {
229		panic(makeErrorWithDetails(
230			errInvalidUnstakingFee,
231			ufmt.Sprintf("fee(%d) must be in range 0 ~ %d", fee, maxUnstakingFee),
232		))
233	}
234}
235
236// assertIsValidIncentiveStartTime ensures the incentive starts at midnight no earlier than 24 hours after creation
237// and no later than 7 days after the first eligible midnight.
238func assertIsValidIncentiveStartTime(startTimestamp int64) {
239	// must be in seconds format, not milliseconds
240	// REF: https://stackoverflow.com/a/23982005
241	numStr := strconv.Itoa(int(startTimestamp))
242
243	if len(numStr) >= 13 {
244		panic(makeErrorWithDetails(
245			errInvalidIncentiveStartTime,
246			ufmt.Sprintf("startTimestamp(%d) must be in seconds format, not milliseconds", startTimestamp),
247		))
248	}
249
250	// must be at least 24 hours from now
251	minimumStartTimestamp := getMinimumIncentiveStartTimestamp()
252	if startTimestamp < minimumStartTimestamp {
253		panic(makeErrorWithDetails(
254			errInvalidIncentiveStartTime,
255			ufmt.Sprintf("startTimestamp(%d) must be at least 24 hours later", startTimestamp),
256		))
257	}
258
259	maximumStartTimestamp := getMaximumIncentiveStartTimestamp()
260	if startTimestamp > maximumStartTimestamp {
261		panic(makeErrorWithDetails(
262			errInvalidIncentiveStartTime,
263			ufmt.Sprintf("startTimestamp(%d) exceeds the maximum start time policy", startTimestamp),
264		))
265	}
266
267	// must be midnight of the day
268	startTime := time.Unix(startTimestamp, 0)
269	if !isMidnight(startTime) {
270		panic(makeErrorWithDetails(
271			errInvalidIncentiveStartTime,
272			ufmt.Sprintf("startTime(%d = %s) must be midnight of the day", startTimestamp, startTime.String()),
273		))
274	}
275}
276
277// assertIsAdminGovernanceOrCreator ensures the caller may act on an incentive:
278// admin and governance are the protocol-level operators, and the creator is the
279// address that funded the incentive in CreateExternalIncentive.
280func assertIsAdminGovernanceOrCreator(caller, creator address) {
281	if caller == creator {
282		return
283	}
284	if access.IsAuthorized(prbac.ROLE_ADMIN.String(), caller) ||
285		access.IsAuthorized(prbac.ROLE_GOVERNANCE.String(), caller) {
286		return
287	}
288
289	panic(makeErrorWithDetails(
290		errNoPermission,
291		ufmt.Sprintf(
292			"caller(%s) must be admin, governance, or the incentive creator(%s)",
293			caller.String(), creator.String(),
294		),
295	))
296}
297
298// assertIsNotStartedIncentive ensures the incentive can still be cancelled, i.e.
299// it has not started and nothing has been distributed from it. Only such an
300// incentive may be removed outright; anything that already emitted rewards has
301// to be finalized through EndExternalIncentive so the refund accounting and the
302// penalty balance stay reachable.
303func assertIsNotStartedIncentive(incentiveResolver *ExternalIncentiveResolver, currentTime int64) {
304	if incentiveResolver.IsStarted(currentTime) {
305		panic(makeErrorWithDetails(
306			errCannotCancelIncentive,
307			ufmt.Sprintf(
308				"incentive(%s) already started at %d, current(%d)",
309				incentiveResolver.IncentiveId(), incentiveResolver.StartTimestamp(), currentTime,
310			),
311		))
312	}
313
314	if incentiveResolver.Refunded() {
315		panic(makeErrorWithDetails(
316			errCannotCancelIncentive,
317			ufmt.Sprintf("incentive(%s) has already been refunded", incentiveResolver.IncentiveId()),
318		))
319	}
320
321	// Defensive: a not-yet-started incentive can never have distributed a reward
322	// or accrued a warmup penalty. Removing a record that carries either would
323	// strand those tokens in the staker, so refuse instead of dropping it.
324	if incentiveResolver.DistributedRewardAmount() != 0 || incentiveResolver.AccumulatedPenaltyAmount() != 0 {
325		panic(makeErrorWithDetails(
326			errCannotCancelIncentive,
327			ufmt.Sprintf(
328				"incentive(%s) has already accrued rewards: distributed(%d), penalty(%d)",
329				incentiveResolver.IncentiveId(),
330				incentiveResolver.DistributedRewardAmount(),
331				incentiveResolver.AccumulatedPenaltyAmount(),
332			),
333		))
334	}
335}
336
337// getMinimumIncentiveStartTimestamp returns after 24 hours from now.
338func getMinimumIncentiveStartTimestamp() int64 {
339	return time.Now().Add(24 * time.Hour).Unix()
340}
341
342// getMaximumIncentiveStartTimestamp returns 7 days after the first midnight that satisfies
343// the minimum 24-hour delay.
344func getMaximumIncentiveStartTimestamp() int64 {
345	minimumStartTimestamp := getMinimumIncentiveStartTimestamp()
346	firstEligibleMidnight := time.Unix(minimumStartTimestamp, 0).Truncate(24 * time.Hour)
347	if firstEligibleMidnight.Unix() < minimumStartTimestamp {
348		firstEligibleMidnight = firstEligibleMidnight.Add(24 * time.Hour)
349	}
350
351	return firstEligibleMidnight.Add(maxIncentiveStartDelay).Unix()
352}
353
354// assertIsValidIncentiveEndTime ensures the end timestamp is within valid epoch range.
355func assertIsValidIncentiveEndTime(endTimestamp int64) {
356	if endTimestamp >= MAX_UNIX_EPOCH_TIME {
357		panic(makeErrorWithDetails(
358			errInvalidInput,
359			ufmt.Sprintf("endTimestamp(%d) cannot be later than 253402300799 (9999-12-31 23:59:59)", endTimestamp),
360		))
361	}
362}
363
364// assertIsValidIncentiveDuration ensures the duration is 90, 180, or 365 days.
365func assertIsValidIncentiveDuration(externalDuration int64) {
366	switch externalDuration {
367	case TIMESTAMP_90DAYS, TIMESTAMP_180DAYS, TIMESTAMP_365DAYS:
368		return
369	}
370
371	panic(makeErrorWithDetails(
372		errInvalidIncentiveDuration,
373		ufmt.Sprintf("externalDuration(%d) must be 90, 180, 365 days", externalDuration),
374	))
375}
376
377// AssertIsValidAddress panics if the provided address is invalid.
378func assertIsValidAddress(addr address) {
379	if addr == "" || !addr.IsValid() {
380		panic(makeErrorWithDetails(
381			errInvalidAddress,
382			ufmt.Sprintf("address(%s) is invalid", addr.String()),
383		))
384	}
385}
386
387// isMidnight checks if a time represents midnight (00:00:00).
388func isMidnight(startTime time.Time) bool {
389	hour := startTime.Hour()
390	minute := startTime.Minute()
391	second := startTime.Second()
392
393	return hour == 0 && minute == 0 && second == 0
394}
395
396// assertIsPositionOwner validates that the caller has permission to operate the token.
397func assertIsPositionOwner(owner, caller address) {
398	if owner != caller {
399		panic(makeErrorWithDetails(
400			errNoPermission,
401			ufmt.Sprintf("caller(%s) is not owner of positionId(%s)", caller, owner),
402		))
403	}
404}