Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions 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}