launchpad_project.gno
17.84 Kb · 588 lines
1package launchpad
2
3import (
4 "chain"
5 "chain/runtime"
6 "errors"
7 "strconv"
8 "strings"
9 "time"
10
11 gnsmath "gno.land/p/gnoswap/gnsmath"
12 "gno.land/p/gnoswap/utils"
13 ufmt "gno.land/p/nt/ufmt/v0"
14
15 "gno.land/r/gnoswap/access"
16 "gno.land/r/gnoswap/common"
17 "gno.land/r/gnoswap/emission"
18 "gno.land/r/gnoswap/halt"
19 "gno.land/r/gnoswap/launchpad"
20)
21
22// CreateProject creates a new launchpad project with tiered allocations.
23//
24// Parameters:
25// - name: project name
26// - tokenPath: reward token contract path
27// - recipient: project recipient address
28// - depositAmount: amount of tokens to deposit
29// - conditionTokens: comma-separated token paths for conditions
30// - conditionAmounts: comma-separated minimum amounts for conditions
31// - tier30Ratio: allocation ratio for 30-day tier
32// - tier90Ratio: allocation ratio for 90-day tier
33// - tier180Ratio: allocation ratio for 180-day tier
34// - startTime: unix timestamp for project start
35//
36// Returns project ID.
37// Only callable by admin or governance.
38func (lp *launchpadV1) CreateProject(
39 _ int,
40 rlm realm,
41 name string,
42 tokenPath string,
43 recipient address,
44 depositAmount int64,
45 conditionTokens string,
46 conditionAmounts string,
47 tier30Ratio int64,
48 tier90Ratio int64,
49 tier180Ratio int64,
50 startTime int64,
51) string {
52 if !rlm.IsCurrent() {
53 panic(errors.New(errSpoofedRealm))
54 }
55
56 halt.AssertIsNotHaltedLaunchpad()
57
58 previousRealm := rlm.Previous()
59 caller := previousRealm.Address()
60 access.AssertIsAdminOrGovernance(caller)
61
62 launchpadAddr := rlm.Address()
63 currentHeight := runtime.ChainHeight()
64 currentTime := time.Now().Unix()
65
66 params := &createProjectParams{
67 name: name,
68 tokenPath: tokenPath,
69 recipient: recipient,
70 depositAmount: depositAmount,
71 conditionTokens: conditionTokens,
72 conditionAmounts: conditionAmounts,
73 tier30Ratio: tier30Ratio,
74 tier90Ratio: tier90Ratio,
75 tier180Ratio: tier180Ratio,
76 startTime: startTime,
77 currentTime: currentTime,
78 currentHeight: currentHeight,
79 minimumStartDelayTime: projectMinimumStartDelayTime,
80 }
81
82 // Checks: validate balance before creating project
83 tokenBalance := common.BalanceOf(tokenPath, caller)
84 if tokenBalance < depositAmount {
85 panic(
86 makeErrorWithDetails(
87 errInsufficientBalance, ufmt.Sprintf(
88 "caller(%s) balance(%d) < depositAmount(%d)",
89 caller.String(), tokenBalance, depositAmount,
90 ),
91 ),
92 )
93 }
94
95 // Effects: create project and save state
96 project, err := lp.createProject(0, rlm, params)
97 if err != nil {
98 panic(err)
99 }
100
101 // Interactions: transfer tokens
102 common.SafeGRC20TransferFrom(
103 cross(rlm),
104 tokenPath,
105 caller,
106 launchpadAddr,
107 depositAmount,
108 )
109
110 tier30, err := getProjectTier(project, projectTier30)
111 if err != nil {
112 panic(err)
113 }
114
115 tier90, err := getProjectTier(project, projectTier90)
116 if err != nil {
117 panic(err)
118 }
119
120 tier180, err := getProjectTier(project, projectTier180)
121 if err != nil {
122 panic(err)
123 }
124
125 conditionEventAttrs := buildConditionEventAttrs(params.conditionTokens, params.conditionAmounts)
126
127 eventAttrs := append([]string{
128 "prevAddr", caller.String(),
129 "prevRealm", previousRealm.PkgPath(),
130 "name", name,
131 "tokenPath", tokenPath,
132 "recipient", recipient.String(),
133 "depositAmount", utils.FormatInt(depositAmount),
134 "tier30Ratio", utils.FormatInt(params.tier30Ratio),
135 "tier90Ratio", utils.FormatInt(params.tier90Ratio),
136 "tier180Ratio", utils.FormatInt(params.tier180Ratio),
137 "startTime", utils.FormatInt(params.startTime),
138 "projectId", project.ID(),
139 "tier30Amount", utils.FormatInt(tier30.TotalDistributeAmount()),
140 "tier30EndTime", utils.FormatInt(tier30.EndTime()),
141 "tier90Amount", utils.FormatInt(tier90.TotalDistributeAmount()),
142 "tier90EndTime", utils.FormatInt(tier90.EndTime()),
143 "tier180Amount", utils.FormatInt(tier180.TotalDistributeAmount()),
144 "tier180EndTime", utils.FormatInt(tier180.EndTime()),
145 }, conditionEventAttrs...)
146
147 chain.Emit(
148 "CreateProject",
149 eventAttrs...,
150 )
151
152 return project.ID()
153}
154
155// createProject creates a new project with the given parameters.
156// This function validates the input parameters, creates the project structure,
157// and sets up the project tiers and reward managers.
158// Returns the created project and any error.
159func (lp *launchpadV1) createProject(_ int, rlm realm, params *createProjectParams) (*launchpad.Project, error) {
160 if err := params.validate(); err != nil {
161 return nil, err
162 }
163
164 // create project
165 project := launchpad.NewProject(
166 params.name,
167 params.tokenPath,
168 params.depositAmount,
169 params.recipient,
170 params.currentHeight,
171 params.currentTime,
172 )
173
174 // Get state components
175 projects := lp.store.GetProjects()
176 projectTierRewardManagers := lp.store.GetProjectTierRewardManagers()
177
178 // check duplicate project
179 if projects.Has(project.ID()) {
180 return nil, makeErrorWithDetails(
181 errDuplicateProject,
182 ufmt.Sprintf("project(%s) already exists", project.ID()),
183 )
184 }
185
186 projectConditions, err := launchpad.NewProjectConditionsWithError(params.conditionTokens, params.conditionAmounts)
187 if err != nil {
188 return nil, err
189 }
190
191 for _, condition := range projectConditions {
192 addProjectCondition(project, condition.TokenPath(), condition)
193 }
194
195 projectTierRatios := map[int64]int64{
196 projectTier30: params.tier30Ratio,
197 projectTier90: params.tier90Ratio,
198 projectTier180: params.tier180Ratio,
199 }
200
201 accumulatedTierDistributeAmount := int64(0)
202
203 for _, duration := range projectTierDurations {
204 rewardCollectableDuration := projectTierRewardCollectableDuration[duration]
205 tierDurationTime := projectTierDurationTimes[duration]
206 tierDistributeAmount := gnsmath.SafeMulDivInt64(params.depositAmount, projectTierRatios[duration], 100)
207 accumulatedTierDistributeAmount = gnsmath.SafeAddInt64(accumulatedTierDistributeAmount, tierDistributeAmount)
208
209 // if the last tier, distribute the remaining amount
210 if duration == projectTier180 {
211 remainTierDistributeAmount := gnsmath.SafeSubInt64(params.depositAmount, accumulatedTierDistributeAmount)
212 tierDistributeAmount = gnsmath.SafeAddInt64(tierDistributeAmount, remainTierDistributeAmount)
213 }
214
215 projectTier := newProjectTier(
216 project.ID(),
217 duration,
218 tierDistributeAmount,
219 params.startTime,
220 params.startTime+tierDurationTime,
221 )
222 addProjectTier(project, duration, projectTier)
223
224 projectTierRewardManagers.Set(projectTier.ID(), newRewardManager(
225 projectTier.TotalDistributeAmount(),
226 projectTier.StartTime(),
227 projectTier.EndTime(),
228 rewardCollectableDuration,
229 ))
230 }
231
232 project.SetTiersRatios(projectTierRatios)
233 projects.Set(project.ID(), project)
234
235 // Save the modified state back
236 if err := lp.store.SetProjects(0, rlm, projects); err != nil {
237 return nil, err
238 }
239 if err := lp.store.SetProjectTierRewardManagers(0, rlm, projectTierRewardManagers); err != nil {
240 return nil, err
241 }
242
243 return project, nil
244}
245
246// TransferLeftFromProjectByAdmin transfers the remaining rewards of a project to a specified recipient.
247// Only admin can call this function. Returns the amount of rewards transferred.
248func (lp *launchpadV1) TransferLeftFromProjectByAdmin(_ int, rlm realm, projectID string, recipient address) int64 {
249 if !rlm.IsCurrent() {
250 panic(errors.New(errSpoofedRealm))
251 }
252
253 halt.AssertIsNotHaltedLaunchpad()
254
255 previousRealm := rlm.Previous()
256 caller := previousRealm.Address()
257 access.AssertIsAdmin(caller)
258
259 currentHeight := runtime.ChainHeight()
260 currentTime := time.Now().Unix()
261
262 project, err := lp.getProject(projectID)
263 if err != nil {
264 panic(err)
265 }
266
267 projectLeftReward, err := lp.transferLeftFromProject(0, rlm, project, recipient, currentTime)
268 if err != nil {
269 panic(err)
270 }
271
272 tier30, err := getProjectTier(project, projectTier30)
273 if err != nil {
274 panic(err)
275 }
276
277 tier90, err := getProjectTier(project, projectTier90)
278 if err != nil {
279 panic(err)
280 }
281
282 tier180, err := getProjectTier(project, projectTier180)
283 if err != nil {
284 panic(err)
285 }
286
287 chain.Emit(
288 "TransferLeftFromProjectByAdmin",
289 "prevAddr", caller.String(),
290 "prevRealm", previousRealm.PkgPath(),
291 "projectId", projectID,
292 "recipient", recipient.String(),
293 "tokenPath", project.TokenPath(),
294 "leftReward", utils.FormatInt(projectLeftReward),
295 "tier30Full", utils.FormatInt(tier30.TotalDepositAmount()),
296 "tier30Left", utils.FormatInt(getCalculatedLeftReward(tier30)),
297 "tier90Full", utils.FormatInt(tier90.TotalDepositAmount()),
298 "tier90Left", utils.FormatInt(getCalculatedLeftReward(tier90)),
299 "tier180Full", utils.FormatInt(tier180.TotalDepositAmount()),
300 "tier180Left", utils.FormatInt(getCalculatedLeftReward(tier180)),
301 "currentHeight", utils.FormatInt(currentHeight),
302 "currentTime", utils.FormatInt(currentTime),
303 )
304
305 return projectLeftReward
306}
307
308// transferLeftFromProject transfers the remaining rewards of a project to a specified recipient.
309// This function is called by an admin to transfer any unclaimed rewards from a project to a recipient address.
310// It validates the project ID, checks the recipient conditions, calculates the remaining rewards, and performs the transfer.
311// Returns the amount of rewards transferred to the recipient and any error.
312func (lp *launchpadV1) transferLeftFromProject(_ int, rlm realm, project *launchpad.Project, recipient address, currentTime int64) (int64, error) {
313 if err := validateRefundProject(project, recipient, currentTime); err != nil {
314 return 0, err
315 }
316
317 emission.MintAndDistributeGns(cross(rlm))
318
319 accumTotalDistributeAmount := int64(0)
320 accumLeftReward := int64(0)
321 accumCollectedReward := int64(0)
322 accumCollectableReward := int64(0)
323 refundableAmountsByTier := make(map[int64]int64)
324
325 for duration, tier := range project.Tiers() {
326 // Calculate pending rewards for remaining depositors
327 currentDepositCount := getTierCurrentDepositCount(tier)
328 tierCollectableReward := int64(0)
329
330 if currentDepositCount > 0 {
331 var err error
332 tierCollectableReward, err = lp.applyTierRewardGrowthWithRewards(tier, currentTime)
333 if err != nil {
334 return 0, err
335 }
336
337 accumCollectableReward = gnsmath.SafeAddInt64(accumCollectableReward, tierCollectableReward)
338 }
339
340 leftReward := getCalculatedLeftReward(tier)
341 tierRefundableAmount := gnsmath.SafeSubInt64(leftReward, tierCollectableReward)
342 if tierRefundableAmount < 0 {
343 return 0, errors.New(ufmt.Sprintf("tierRefundableAmount(%d) < 0", tierRefundableAmount))
344 }
345
346 refundableAmountsByTier[duration] = tierRefundableAmount
347 accumLeftReward = gnsmath.SafeAddInt64(accumLeftReward, leftReward)
348 accumCollectedReward = gnsmath.SafeAddInt64(accumCollectedReward, tier.TotalCollectedAmount())
349 accumTotalDistributeAmount = gnsmath.SafeAddInt64(accumTotalDistributeAmount, tier.TotalDistributeAmount())
350 }
351
352 if accumLeftReward == 0 {
353 return 0, errors.New("project has no remaining amount")
354 }
355
356 actualTotalDistributeAmount := gnsmath.SafeAddInt64(accumCollectedReward, accumLeftReward)
357 if accumTotalDistributeAmount != actualTotalDistributeAmount {
358 return 0, errors.New(ufmt.Sprintf("accumTotalDistributeAmount(%d) != accumCollectedReward(%d)+accumLeftReward(%d)", accumTotalDistributeAmount, accumCollectedReward, accumLeftReward))
359 }
360
361 // Calculate refundable amount: project remaining minus collectable rewards for remaining depositors
362 projectLeftReward := accumLeftReward
363 projectRefundableAmount := gnsmath.SafeSubInt64(projectLeftReward, accumCollectableReward)
364
365 if projectRefundableAmount < 0 {
366 return 0, errors.New(ufmt.Sprintf("projectRefundableAmount(%d) < 0", projectRefundableAmount))
367 }
368
369 if projectRefundableAmount > 0 {
370 updatedTiers := make(map[int64]*launchpad.ProjectTier)
371 for duration, tier := range project.Tiers() {
372 tierRefundableAmount := refundableAmountsByTier[duration]
373 if tierRefundableAmount > 0 {
374 tier.SetTotalCollectedAmount(gnsmath.SafeAddInt64(tier.TotalCollectedAmount(), tierRefundableAmount))
375 }
376
377 updatedTiers[duration] = tier
378 }
379 project.SetTiers(updatedTiers)
380
381 common.SafeGRC20Transfer(cross(rlm), project.TokenPath(), recipient, projectRefundableAmount)
382 }
383
384 return projectRefundableAmount, nil
385}
386
387// applyTierRewardGrowthWithRewards calculates the total collectable rewards
388// for all active deposits in a specific tier.
389func (lp *launchpadV1) applyTierRewardGrowthWithRewards(tier *launchpad.ProjectTier, currentTime int64) (int64, error) {
390 rewardManager, err := lp.getProjectTierRewardManager(tier.ID())
391 if err != nil {
392 return 0, err
393 }
394
395 // Update reward accumulation to current time before calculating collectable rewards.
396 err = updateRewardPerDepositX128(rewardManager, getTierCurrentDepositAmount(tier), currentTime)
397 if err != nil {
398 return 0, err
399 }
400
401 emitUpdateLaunchpadRewardAccumulation(tier.ID(), rewardManager, getTierCurrentDepositAmount(tier))
402
403 return calculateClaimableRewardsForActiveDeposits(rewardManager), nil
404}
405
406// validateTransferLeft validates the transfer of remaining tokens
407func validateRefundProject(project *launchpad.Project, recipient address, currentTime int64) error {
408 if !recipient.IsValid() {
409 return errors.New(ufmt.Sprintf("invalid recipient address(%s)", recipient.String()))
410 }
411
412 return validateRefundRemainingAmount(project, currentTime)
413}
414
415type createProjectParams struct {
416 name string
417 tokenPath string
418 recipient address
419 depositAmount int64
420 conditionTokens string
421 conditionAmounts string
422 tier30Ratio int64
423 tier90Ratio int64
424 tier180Ratio int64
425 startTime int64
426 currentTime int64
427 currentHeight int64
428 minimumStartDelayTime int64
429}
430
431func (p *createProjectParams) validate() error {
432 if err := p.validateName(); err != nil {
433 return err
434 }
435
436 if err := p.validateTokenPath(); err != nil {
437 return err
438 }
439
440 if err := p.validateRecipient(); err != nil {
441 return err
442 }
443
444 if err := p.validateDepositAmount(); err != nil {
445 return err
446 }
447
448 if err := p.validateRatio(); err != nil {
449 return err
450 }
451
452 if err := p.validateStartTime(p.currentTime, p.minimumStartDelayTime); err != nil {
453 return err
454 }
455
456 if err := p.validateConditions(); err != nil {
457 return err
458 }
459
460 return nil
461}
462
463// validateName checks if the project name is valid.
464func (p *createProjectParams) validateName() error {
465 if p.name == "" {
466 return makeErrorWithDetails(errInvalidInput, "project name cannot be empty")
467 }
468
469 if len(p.name) > 100 {
470 return makeErrorWithDetails(errInvalidInput, "project name is too long")
471 }
472
473 return nil
474}
475
476// validateTokenPath validates the token path is not empty and is registered.
477func (p *createProjectParams) validateTokenPath() error {
478 if p.tokenPath == "" {
479 return makeErrorWithDetails(errInvalidInput, "tokenPath cannot be empty")
480 }
481
482 if err := common.IsRegistered(p.tokenPath); err != nil && !isGovernanceToken(p.tokenPath) {
483 return makeErrorWithDetails(errInvalidInput, ufmt.Sprintf("tokenPath(%s) not registered", p.tokenPath))
484 }
485
486 return nil
487}
488
489// validateRecipient checks if the recipient address is valid.
490func (p *createProjectParams) validateRecipient() error {
491 if !p.recipient.IsValid() {
492 return makeErrorWithDetails(errInvalidAddress, ufmt.Sprintf("recipient address(%s)", p.recipient.String()))
493 }
494
495 return nil
496}
497
498// validateDepositAmount ensures that the deposit amount is greater than zero.
499func (p *createProjectParams) validateDepositAmount() error {
500 if p.depositAmount == 0 {
501 return makeErrorWithDetails(errInvalidInput, "deposit amount cannot be 0")
502 }
503
504 if p.depositAmount < 0 {
505 return makeErrorWithDetails(errInvalidInput, "deposit amount cannot be negative")
506 }
507
508 return nil
509}
510
511// validateRatio checks if each tier ratio is non-negative and the sum equals 100.
512func (p *createProjectParams) validateRatio() error {
513 if p.tier30Ratio < 0 || p.tier90Ratio < 0 || p.tier180Ratio < 0 {
514 return makeErrorWithDetails(
515 errInvalidInput,
516 ufmt.Sprintf("tier ratios must be non-negative (30:%d, 90:%d, 180:%d)", p.tier30Ratio, p.tier90Ratio, p.tier180Ratio),
517 )
518 }
519
520 sum := p.tier30Ratio + p.tier90Ratio + p.tier180Ratio
521 if sum != 100 {
522 return makeErrorWithDetails(
523 errInvalidInput,
524 ufmt.Sprintf("invalid ratio, sum of all tiers(30:%d, 90:%d, 180:%d) should be 100", p.tier30Ratio, p.tier90Ratio, p.tier180Ratio),
525 )
526 }
527
528 return nil
529}
530
531// validateStartTime checks if the start time is available with minimum delay requirement.
532func (p *createProjectParams) validateStartTime(now int64, minimumStartDelayTime int64) error {
533 availableStartTime := now + minimumStartDelayTime
534
535 if p.startTime < availableStartTime {
536 return makeErrorWithDetails(errInvalidInput, ufmt.Sprintf("start time(%d) must be greater than now(%d)", p.startTime, availableStartTime))
537 }
538
539 return nil
540}
541
542func (p *createProjectParams) validateConditions() error {
543 if p.conditionTokens == "" && p.conditionAmounts == "" {
544 return nil
545 }
546
547 tokenPaths := strings.Split(p.conditionTokens, stringSplitterPad)
548 minimumAmounts := strings.Split(p.conditionAmounts, stringSplitterPad)
549
550 if len(tokenPaths) != len(minimumAmounts) {
551 return makeErrorWithDetails(errInvalidInput, "conditionTokens and conditionAmounts are not matched")
552 }
553 if len(tokenPaths) > maxProjectConditionCount {
554 return makeErrorWithDetails(errInvalidInput, ufmt.Sprintf("condition count(%d) exceeds maximum(%d)", len(tokenPaths), maxProjectConditionCount))
555 }
556
557 tokenPathMap := make(map[string]bool)
558
559 for _, tokenPath := range tokenPaths {
560 err := common.IsRegistered(tokenPath)
561 if err != nil && !isGovernanceToken(tokenPath) {
562 return makeErrorWithDetails(errInvalidInput, ufmt.Sprintf("tokenPath(%s) not registered", tokenPath))
563 }
564
565 if tokenPathMap[tokenPath] {
566 return makeErrorWithDetails(errInvalidInput, ufmt.Sprintf("tokenPath(%s) is duplicated", tokenPath))
567 }
568
569 tokenPathMap[tokenPath] = true
570 }
571
572 for _, amountStr := range minimumAmounts {
573 minimumAmount, err := strconv.ParseInt(amountStr, 10, 64)
574 if err != nil {
575 return makeErrorWithDetails(errInvalidInput, ufmt.Sprintf("invalid condition amount(%s)", amountStr))
576 }
577
578 if minimumAmount <= 0 {
579 return makeErrorWithDetails(errInvalidInput, ufmt.Sprintf("condition amount(%s) is not available", amountStr))
580 }
581 }
582
583 return nil
584}
585
586func isGovernanceToken(tokenPath string) bool {
587 return tokenPath == GOV_XGNS_PATH
588}