Search Apps Documentation Source Content File Folder Download Copy Actions Download

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}