launchpad_project.gno
17.02 Kb · 568 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
324 for _, tier := range project.Tiers() {
325 // Calculate pending rewards for remaining depositors
326 currentDepositCount := getTierCurrentDepositCount(tier)
327
328 if currentDepositCount > 0 {
329 claimableReward, err := lp.applyTierRewardGrowthWithRewards(tier, currentTime)
330 if err != nil {
331 return 0, err
332 }
333
334 accumCollectableReward = gnsmath.SafeAddInt64(accumCollectableReward, claimableReward)
335 }
336
337 leftReward := getCalculatedLeftReward(tier)
338 accumLeftReward = gnsmath.SafeAddInt64(accumLeftReward, leftReward)
339 accumCollectedReward = gnsmath.SafeAddInt64(accumCollectedReward, tier.TotalCollectedAmount())
340 accumTotalDistributeAmount = gnsmath.SafeAddInt64(accumTotalDistributeAmount, tier.TotalDistributeAmount())
341 }
342
343 if accumLeftReward == 0 {
344 return 0, errors.New("project has no remaining amount")
345 }
346
347 actualTotalDistributeAmount := gnsmath.SafeAddInt64(accumCollectedReward, accumLeftReward)
348 if accumTotalDistributeAmount != actualTotalDistributeAmount {
349 return 0, errors.New(ufmt.Sprintf("accumTotalDistributeAmount(%d) != accumCollectedReward(%d)+accumLeftReward(%d)", accumTotalDistributeAmount, accumCollectedReward, accumLeftReward))
350 }
351
352 // Calculate refundable amount: project remaining minus claimable rewards for remaining depositors
353 projectLeftReward := accumLeftReward
354 refundableAmount := gnsmath.SafeSubInt64(projectLeftReward, accumCollectableReward)
355
356 if refundableAmount < 0 {
357 return 0, errors.New(ufmt.Sprintf("refundableAmount(%d) < 0", refundableAmount))
358 }
359
360 if refundableAmount > 0 {
361 common.SafeGRC20Transfer(cross(rlm), project.TokenPath(), recipient, refundableAmount)
362 }
363
364 return refundableAmount, nil
365}
366
367// applyTierRewardGrowthWithRewards calculates the total claimable rewards
368// for all active deposits in a specific tier.
369func (lp *launchpadV1) applyTierRewardGrowthWithRewards(tier *launchpad.ProjectTier, currentTime int64) (int64, error) {
370 rewardManager, err := lp.getProjectTierRewardManager(tier.ID())
371 if err != nil {
372 return 0, err
373 }
374
375 // Update reward accumulation to current time before calculating claimable.
376 err = updateRewardPerDepositX128(rewardManager, getTierCurrentDepositAmount(tier), currentTime)
377 if err != nil {
378 return 0, err
379 }
380
381 emitUpdateLaunchpadRewardAccumulation(tier.ID(), rewardManager, getTierCurrentDepositAmount(tier))
382
383 return calculateClaimableRewardsForActiveDeposits(rewardManager), nil
384}
385
386// validateTransferLeft validates the transfer of remaining tokens
387func validateRefundProject(project *launchpad.Project, recipient address, currentTime int64) error {
388 if !recipient.IsValid() {
389 return errors.New(ufmt.Sprintf("invalid recipient address(%s)", recipient.String()))
390 }
391
392 return validateRefundRemainingAmount(project, currentTime)
393}
394
395type createProjectParams struct {
396 name string
397 tokenPath string
398 recipient address
399 depositAmount int64
400 conditionTokens string
401 conditionAmounts string
402 tier30Ratio int64
403 tier90Ratio int64
404 tier180Ratio int64
405 startTime int64
406 currentTime int64
407 currentHeight int64
408 minimumStartDelayTime int64
409}
410
411func (p *createProjectParams) validate() error {
412 if err := p.validateName(); err != nil {
413 return err
414 }
415
416 if err := p.validateTokenPath(); err != nil {
417 return err
418 }
419
420 if err := p.validateRecipient(); err != nil {
421 return err
422 }
423
424 if err := p.validateDepositAmount(); err != nil {
425 return err
426 }
427
428 if err := p.validateRatio(); err != nil {
429 return err
430 }
431
432 if err := p.validateStartTime(p.currentTime, p.minimumStartDelayTime); err != nil {
433 return err
434 }
435
436 if err := p.validateConditions(); err != nil {
437 return err
438 }
439
440 return nil
441}
442
443// validateName checks if the project name is valid.
444func (p *createProjectParams) validateName() error {
445 if p.name == "" {
446 return makeErrorWithDetails(errInvalidInput, "project name cannot be empty")
447 }
448
449 if len(p.name) > 100 {
450 return makeErrorWithDetails(errInvalidInput, "project name is too long")
451 }
452
453 return nil
454}
455
456// validateTokenPath validates the token path is not empty and is registered.
457func (p *createProjectParams) validateTokenPath() error {
458 if p.tokenPath == "" {
459 return makeErrorWithDetails(errInvalidInput, "tokenPath cannot be empty")
460 }
461
462 if err := common.IsRegistered(p.tokenPath); err != nil && !isGovernanceToken(p.tokenPath) {
463 return makeErrorWithDetails(errInvalidInput, ufmt.Sprintf("tokenPath(%s) not registered", p.tokenPath))
464 }
465
466 return nil
467}
468
469// validateRecipient checks if the recipient address is valid.
470func (p *createProjectParams) validateRecipient() error {
471 if !p.recipient.IsValid() {
472 return makeErrorWithDetails(errInvalidAddress, ufmt.Sprintf("recipient address(%s)", p.recipient.String()))
473 }
474
475 return nil
476}
477
478// validateDepositAmount ensures that the deposit amount is greater than zero.
479func (p *createProjectParams) validateDepositAmount() error {
480 if p.depositAmount == 0 {
481 return makeErrorWithDetails(errInvalidInput, "deposit amount cannot be 0")
482 }
483
484 if p.depositAmount < 0 {
485 return makeErrorWithDetails(errInvalidInput, "deposit amount cannot be negative")
486 }
487
488 return nil
489}
490
491// validateRatio checks if each tier ratio is non-negative and the sum equals 100.
492func (p *createProjectParams) validateRatio() error {
493 if p.tier30Ratio < 0 || p.tier90Ratio < 0 || p.tier180Ratio < 0 {
494 return makeErrorWithDetails(
495 errInvalidInput,
496 ufmt.Sprintf("tier ratios must be non-negative (30:%d, 90:%d, 180:%d)", p.tier30Ratio, p.tier90Ratio, p.tier180Ratio),
497 )
498 }
499
500 sum := p.tier30Ratio + p.tier90Ratio + p.tier180Ratio
501 if sum != 100 {
502 return makeErrorWithDetails(
503 errInvalidInput,
504 ufmt.Sprintf("invalid ratio, sum of all tiers(30:%d, 90:%d, 180:%d) should be 100", p.tier30Ratio, p.tier90Ratio, p.tier180Ratio),
505 )
506 }
507
508 return nil
509}
510
511// validateStartTime checks if the start time is available with minimum delay requirement.
512func (p *createProjectParams) validateStartTime(now int64, minimumStartDelayTime int64) error {
513 availableStartTime := now + minimumStartDelayTime
514
515 if p.startTime < availableStartTime {
516 return makeErrorWithDetails(errInvalidInput, ufmt.Sprintf("start time(%d) must be greater than now(%d)", p.startTime, availableStartTime))
517 }
518
519 return nil
520}
521
522func (p *createProjectParams) validateConditions() error {
523 if p.conditionTokens == "" && p.conditionAmounts == "" {
524 return nil
525 }
526
527 tokenPaths := strings.Split(p.conditionTokens, stringSplitterPad)
528 minimumAmounts := strings.Split(p.conditionAmounts, stringSplitterPad)
529
530 if len(tokenPaths) != len(minimumAmounts) {
531 return makeErrorWithDetails(errInvalidInput, "conditionTokens and conditionAmounts are not matched")
532 }
533 if len(tokenPaths) > maxProjectConditionCount {
534 return makeErrorWithDetails(errInvalidInput, ufmt.Sprintf("condition count(%d) exceeds maximum(%d)", len(tokenPaths), maxProjectConditionCount))
535 }
536
537 tokenPathMap := make(map[string]bool)
538
539 for _, tokenPath := range tokenPaths {
540 err := common.IsRegistered(tokenPath)
541 if err != nil && !isGovernanceToken(tokenPath) {
542 return makeErrorWithDetails(errInvalidInput, ufmt.Sprintf("tokenPath(%s) not registered", tokenPath))
543 }
544
545 if tokenPathMap[tokenPath] {
546 return makeErrorWithDetails(errInvalidInput, ufmt.Sprintf("tokenPath(%s) is duplicated", tokenPath))
547 }
548
549 tokenPathMap[tokenPath] = true
550 }
551
552 for _, amountStr := range minimumAmounts {
553 minimumAmount, err := strconv.ParseInt(amountStr, 10, 64)
554 if err != nil {
555 return makeErrorWithDetails(errInvalidInput, ufmt.Sprintf("invalid condition amount(%s)", amountStr))
556 }
557
558 if minimumAmount <= 0 {
559 return makeErrorWithDetails(errInvalidInput, ufmt.Sprintf("condition amount(%s) is not available", amountStr))
560 }
561 }
562
563 return nil
564}
565
566func isGovernanceToken(tokenPath string) bool {
567 return tokenPath == GOV_XGNS_PATH
568}