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

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}