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