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}