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