Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

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}