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

sales.gno

15.73 Kb · 425 lines
  1// Package sales owns launch token custody, direct-creation allocations, fair
  2// sales and airdrops. Quote that reaches it through its entry points is owed
  3// to someone on record: buyers (escrow and refunds), creators (settled
  4// proceeds) or the protocol (fees). Coins sent to it any other way are owed
  5// to no one and never leave. It has no admin and no emergency withdrawal path.
  6package sales
  7
  8import (
  9	"strconv"
 10	"strings"
 11	"time"
 12
 13	"chain"
 14	"chain/banker"
 15	"gno.land/p/samcrew/launchpad/currency/v1"
 16	"gno.land/p/samcrew/launchpad/meta/v1"
 17	sm "gno.land/p/samcrew/launchpad/safemath/v1"
 18	"gno.land/p/samcrew/launchpad/vesting/v1"
 19	"gno.land/r/nt/grc20reg/v0"
 20	"gno.land/r/samcrew/launchpad/config/v1"
 21	"gno.land/r/samcrew/launchpad/tokens/v1"
 22)
 23
 24const salesPath = "gno.land/r/samcrew/launchpad/sales/v1"
 25const launchpadTokenRegistryPrefix = "gno.land/r/samcrew/launchpad/tokens/v1.T"
 26
 27type VestingRecord struct {
 28	Beneficiary        address
 29	PendingBeneficiary address
 30	Total              int64
 31	Claimed            int64
 32	Start              int64
 33	Cliff              int64
 34	Duration           int64
 35	Revocable          bool
 36	Revoked            bool
 37	RevokedVested      int64
 38}
 39
 40var feeLiability = map[string]int64{}
 41
 42func directCaller(cur realm) address {
 43	if !cur.IsCurrent() || !cur.Previous().IsUserCall() {
 44		panic("launchpad/sales: direct user call required")
 45	}
 46	return cur.Previous().Address()
 47}
 48
 49func directFee(version int64, key string) int64 {
 50	p := config.GetVersion(version)
 51	for _, c := range p.Currencies {
 52		if c.Key == key {
 53			if c.DirectCreationFee < 0 {
 54				panic("launchpad/sales: direct fee disabled")
 55			}
 56			return c.DirectCreationFee
 57		}
 58	}
 59	panic("launchpad/sales: currency has no direct terms")
 60}
 61
 62func collectCreationFee(cur realm, key string, amount int64) {
 63	// A Launchpad token held here for vesting, airdrops or sales cannot also
 64	// back a fee or quote liability without a combined same-asset ledger.
 65	if strings.HasPrefix(key, launchpadTokenRegistryPrefix) {
 66		panic("launchpad/sales: launchpad token cannot be quote currency")
 67	}
 68	if amount == 0 {
 69		currency.AssertNoCoins(0, cur)
 70		return
 71	}
 72	collectQuote(cur, key, amount)
 73	feeLiability[key] = sm.Add(feeLiability[key], amount)
 74	assertQuoteCovered(cur, key)
 75}
 76
 77// collectQuote takes amount from the immediate caller, as attached coins or
 78// from a token allowance. Both routes pay from that caller and no one else.
 79func collectQuote(cur realm, key string, amount int64) {
 80	if key == config.NativeCurrency {
 81		currency.CollectNative(0, cur, amount)
 82	} else {
 83		currency.CollectToken(0, cur, grc20reg.MustGet(key), amount)
 84	}
 85}
 86
 87func quoteBalance(self address, key string) int64 {
 88	if key == config.NativeCurrency {
 89		return banker.NewReadonlyBanker().GetCoin(self, key)
 90	}
 91	return grc20reg.MustGet(key).BalanceOf(self)
 92}
 93
 94// owedIn is everything the realm owes in one currency: fees, escrow, refunds
 95// and proceeds.
 96func owedIn(key string) int64 {
 97	owed := sm.Add(feeLiability[key], fairEscrowLiability[key])
 98	owed = sm.Add(owed, fairRefundLiability[key])
 99	return sm.Add(owed, fairProceedsLiability[key])
100}
101
102// assertQuoteCovered follows every movement of quote: the realm must still
103// hold everything it owes in that currency. An exit ends with it; a creation
104// runs it right after collecting its fee.
105func assertQuoteCovered(cur realm, key string) {
106	if quoteBalance(cur.Address(), key) < owedIn(key) {
107		panic("launchpad/sales: quote custody shortfall")
108	}
109}
110
111func payQuote(cur realm, key string, to address, amount int64) {
112	if key == config.NativeCurrency {
113		currency.PayNative(0, cur, to, amount)
114	} else {
115		currency.PayToken(0, cur, grc20reg.MustGet(key), to, amount)
116	}
117}
118
119// SweepFees pays every fee recorded in one currency, creation fees and
120// fair-sale primary fees alike, to the treasury of the current config, and
121// returns the amount. Anyone can call it; the caller chooses neither the
122// receiver nor the amount. A fee is only ever recorded once a treasury is
123// named, and config never lets the treasury be removed.
124func SweepFees(cur realm, currencyKey string) int64 {
125	if !cur.IsCurrent() {
126		panic("launchpad/sales: stale realm context")
127	}
128	currency.AssertNoCoins(0, cur)
129	amount := feeLiability[currencyKey]
130	if amount <= 0 {
131		panic("launchpad/sales: no fees to sweep")
132	}
133	treasury := config.GetVersion(config.GetCurrentVersion()).Treasury
134	// Config refuses to name this realm as treasury. If it ever did, a native
135	// sweep would pay the realm itself: the fees would stop being owed and
136	// stay here for good, and anyone could trigger it. So it is refused here
137	// as well.
138	if treasury == cur.Address() {
139		panic("launchpad/sales: treasury is this realm")
140	}
141	feeLiability[currencyKey] = 0
142	payQuote(cur, currencyKey, treasury, amount)
143	assertQuoteCovered(cur, currencyKey)
144	chain.Emit("LaunchpadFeesSwept", "currency", currencyKey,
145		"treasury", treasury.String(), "amount", strconv.FormatInt(amount, 10),
146		"actor", cur.Previous().Address().String())
147	return amount
148}
149
150// CreateDirect creates a direct token without an initial airdrop. The
151// allocation string is parsed and the whole amount partitioned before any
152// payment. The caller, payer and initial mint's creator are the same live
153// direct user.
154func CreateDirect(cur realm, mode, name, ticker string, decimals int,
155	initialSupply, maxSupply int64, currencyKey string, expectedVersion int64,
156	description, image, website, xHandle, telegram, allocations string) string {
157	return createDirect(cur, mode, name, ticker, decimals, initialSupply,
158		maxSupply, currencyKey, expectedVersion, description, image, website,
159		xHandle, telegram, allocations, "", "", 0)
160}
161
162// CreateDirectWithAirdrop reserves part of the initial mint for one Merkle
163// campaign. The caller commits to the next token ID used in every leaf. If
164// another launch takes that ID first, this transaction rejects before payment.
165func CreateDirectWithAirdrop(cur realm, mode, name, ticker string, decimals int,
166	initialSupply, maxSupply int64, currencyKey string, expectedVersion int64,
167	description, image, website, xHandle, telegram, allocations,
168	expectedID, airdropRoot string, airdropTotal int64) string {
169	return createDirect(cur, mode, name, ticker, decimals, initialSupply,
170		maxSupply, currencyKey, expectedVersion, description, image, website,
171		xHandle, telegram, allocations, expectedID, airdropRoot, airdropTotal)
172}
173
174func createDirect(cur realm, mode, name, ticker string, decimals int,
175	initialSupply, maxSupply int64, currencyKey string, expectedVersion int64,
176	description, image, website, xHandle, telegram, allocations,
177	expectedID, airdropRoot string, airdropTotal int64) string {
178	creator := directCaller(cur)
179	if mode != tokens.ModeDirectFixed && mode != tokens.ModeDirectCap {
180		panic("launchpad/sales: direct mode required")
181	}
182	if airdropTotal > 0 {
183		requireAirdropRoot(airdropRoot)
184		nextID := "T" + strconv.FormatInt(sm.Add(tokens.Count(), 1), 10)
185		if expectedID != nextID {
186			panic("launchpad/sales: airdrop token ID changed")
187		}
188	} else if airdropTotal != 0 || expectedID != "" || airdropRoot != "" {
189		panic("launchpad/sales: invalid airdrop allocation")
190	}
191	parsed, allocated := parseAllocations(allocations)
192	committed := sm.Add(allocated, airdropTotal)
193	if initialSupply <= 0 || committed > initialSupply {
194		panic("launchpad/sales: allocations exceed supply")
195	}
196	config.AssertNewAction(config.LaneDirect, currencyKey, expectedVersion)
197	if airdropTotal > 0 {
198		config.AssertNewAction(config.LaneAirdrop, currencyKey, expectedVersion)
199	}
200	fee := directFee(expectedVersion, currencyKey)
201	collectCreationFee(cur, currencyKey, fee)
202	id := tokens.Create(cross(cur), creator, mode, name, ticker,
203		decimals, initialSupply, maxSupply, currencyKey, expectedVersion,
204		description, image, website, xHandle, telegram)
205	if airdropTotal > 0 && id != expectedID {
206		panic("launchpad/sales: airdrop token ID changed")
207	}
208	retained := airdropTotal
209	for _, a := range parsed {
210		if a.duration == 0 {
211			tokens.Transfer(cross(cur), id, a.beneficiary, a.amount)
212			continue
213		}
214		appendVesting(id, VestingRecord{
215			Beneficiary: a.beneficiary, Total: a.amount,
216			Start: a.start, Cliff: a.cliff, Duration: a.duration,
217			Revocable: a.revocable,
218		})
219		retained = sm.Add(retained, a.amount)
220	}
221	if airdropTotal > 0 {
222		airdrops.Set(id, AirdropRecord{Root: airdropRoot, Total: airdropTotal})
223		chain.Emit("LaunchpadAirdropCreated", "id", id, "creator", creator.String(),
224			"root", airdropRoot, "total", strconv.FormatInt(airdropTotal, 10))
225	}
226	remainder := sm.Sub(initialSupply, committed)
227	if remainder > 0 {
228		tokens.Transfer(cross(cur), id, creator, remainder)
229	}
230	setTokenOwed(id, retained)
231	if tokens.BalanceOf(id, chain.PackageAddress(salesPath)) != retained {
232		panic("launchpad/sales: token custody mismatch")
233	}
234	chain.Emit("LaunchpadDirectCreated", "id", id, "creator", creator.String(),
235		"currency", currencyKey, "fee", strconv.FormatInt(fee, 10),
236		"retained", strconv.FormatInt(retained, 10))
237	return id
238}
239
240// MintCappedAllocated requires the token's current mint authority to be the
241// immediate user or realm caller. Every new unit is assigned to an immediate or vested
242// allocation; there is no implicit receiver or unrecorded sales balance.
243// No additional creation fee is charged, and attached native coins are
244// rejected. Pausing the direct lane stops new mints without blocking claims.
245func MintCappedAllocated(cur realm, id string, amount int64, allocations string) {
246	if !cur.IsCurrent() {
247		panic("launchpad/sales: stale realm context")
248	}
249	currency.AssertNoCoins(0, cur)
250	previous := cur.Previous()
251	authority := previous.Address()
252	if !authority.IsValid() || (!previous.IsUserCall() &&
253		(previous.PkgPath() == "" || authority != chain.PackageAddress(previous.PkgPath()))) {
254		panic("launchpad/sales: immediate authority call required")
255	}
256	parsed, allocated := parseAllocations(allocations)
257	if amount <= 0 || allocated != amount {
258		panic("launchpad/sales: capped mint must be fully allocated")
259	}
260	// The original creator owns existing revocation rights. A successor mint
261	// authority must not create a schedule the former authority can claw back.
262	for _, a := range parsed {
263		if a.revocable {
264			panic("launchpad/sales: later mint vesting cannot be revocable")
265		}
266	}
267	info := tokens.GetInfo(id)
268	if info.Mode != tokens.ModeDirectCap || info.MintRenounced || info.MintAuthority != authority {
269		panic("launchpad/sales: current mint authority only")
270	}
271	config.AssertLaneOpen(config.LaneDirect, info.CurrencyKey)
272	tokens.MintCapped(cross(cur), id, authority, amount)
273	retained := int64(0)
274	for _, a := range parsed {
275		if a.duration == 0 {
276			tokens.Transfer(cross(cur), id, a.beneficiary, a.amount)
277			continue
278		}
279		appendVesting(id, VestingRecord{
280			Beneficiary: a.beneficiary, Total: a.amount,
281			Start: a.start, Cliff: a.cliff, Duration: a.duration,
282			Revocable: a.revocable,
283		})
284		retained = sm.Add(retained, a.amount)
285	}
286	setTokenOwed(id, sm.Add(tokenOwed(id), retained))
287	if tokens.BalanceOf(id, chain.PackageAddress(salesPath)) < tokenOwed(id) {
288		panic("launchpad/sales: token custody shortfall")
289	}
290	chain.Emit("LaunchpadCappedMintAllocated", "id", id,
291		"authority", authority.String(), "amount", strconv.FormatInt(amount, 10),
292		"retained", strconv.FormatInt(retained, 10))
293}
294
295func VestingCount(id string) int { return vestingCount(id) }
296
297func GetVesting(id string, index int) VestingRecord {
298	if index < 0 || index >= vestingCount(id) {
299		panic("launchpad/sales: vesting record not found")
300	}
301	return vestingAt(id, index)
302}
303
304func TokenLiability(id string) int64 { return tokenOwed(id) }
305
306// ClaimVested remains open even when the direct-launch lane is paused. Anyone
307// may trigger release, but the destination is always the recorded beneficiary.
308// This also permits a realm beneficiary to receive its vested tokens.
309func ClaimVested(cur realm, id string, index int) int64 {
310	if !cur.IsCurrent() {
311		panic("launchpad/sales: stale realm context")
312	}
313	currency.AssertNoCoins(0, cur)
314	if index < 0 || index >= vestingCount(id) {
315		panic("launchpad/sales: vesting record not found")
316	}
317	r := vestingAt(id, index)
318	if r.PendingBeneficiary != "" {
319		panic("launchpad/sales: beneficiary move pending")
320	}
321	s := vesting.Schedule{Total: r.Total, Start: r.Start, Cliff: r.Cliff, Duration: r.Duration}
322	amount := int64(0)
323	if r.Revoked {
324		amount = sm.Sub(r.RevokedVested, r.Claimed)
325	} else {
326		amount = s.Releasable(time.Now().Unix(), r.Claimed)
327	}
328	if amount <= 0 {
329		panic("launchpad/sales: nothing vested to claim")
330	}
331	r.Claimed = sm.Add(r.Claimed, amount)
332	setVesting(id, index, r)
333	setTokenOwed(id, sm.Sub(tokenOwed(id), amount))
334	tokens.Transfer(cross(cur), id, r.Beneficiary, amount)
335	if tokens.BalanceOf(id, chain.PackageAddress(salesPath)) < tokenOwed(id) {
336		panic("launchpad/sales: token custody shortfall")
337	}
338	chain.Emit("LaunchpadVestingClaimed", "id", id,
339		"index", strconv.Itoa(index), "beneficiary", r.Beneficiary.String(),
340		"actor", cur.Previous().Address().String(),
341		"amount", strconv.FormatInt(amount, 10))
342	return amount
343}
344
345// RevokeVesting returns only unvested tokens to the original creator. The
346// beneficiary retains its accrued vested entitlement and claim right.
347func RevokeVesting(cur realm, id string, index int) int64 {
348	creator := directCaller(cur)
349	currency.AssertNoCoins(0, cur)
350	if tokens.GetInfo(id).Creator != creator || index < 0 || index >= vestingCount(id) {
351		panic("launchpad/sales: creator vesting only")
352	}
353	r := vestingAt(id, index)
354	if !r.Revocable || r.Revoked {
355		panic("launchpad/sales: vesting is not revocable")
356	}
357	s := vesting.Schedule{Total: r.Total, Start: r.Start, Cliff: r.Cliff, Duration: r.Duration}
358	vested := s.VestedAt(time.Now().Unix())
359	unvested := sm.Sub(r.Total, vested)
360	r.Revoked = true
361	r.RevokedVested = vested
362	setVesting(id, index, r)
363	setTokenOwed(id, sm.Sub(tokenOwed(id), unvested))
364	if unvested > 0 {
365		tokens.Transfer(cross(cur), id, creator, unvested)
366	}
367	if tokens.BalanceOf(id, chain.PackageAddress(salesPath)) < tokenOwed(id) {
368		panic("launchpad/sales: token custody shortfall")
369	}
370	chain.Emit("LaunchpadVestingRevoked", "id", id,
371		"index", strconv.Itoa(index), "creator", creator.String(),
372		"unvested", strconv.FormatInt(unvested, 10),
373		"vested", strconv.FormatInt(vested, 10))
374	return unvested
375}
376
377func vestingCaller(cur realm) address {
378	if !cur.IsCurrent() {
379		panic("launchpad/sales: stale realm context")
380	}
381	currency.AssertNoCoins(0, cur)
382	return cur.Previous().Address()
383}
384
385// ProposeVestingBeneficiary is callable by the current beneficiary, including
386// a beneficiary realm. A pending move freezes claims until acceptance or
387// cancellation, so the old beneficiary cannot drain accrued entitlement.
388func ProposeVestingBeneficiary(cur realm, id string, index int, next address) {
389	who := vestingCaller(cur)
390	r := GetVesting(id, index)
391	if who != r.Beneficiary || !meta.ValidAddress(next) || next == who ||
392		next == chain.PackageAddress(salesPath) {
393		panic("launchpad/sales: beneficiary proposal denied")
394	}
395	r.PendingBeneficiary = next
396	setVesting(id, index, r)
397	chain.Emit("LaunchpadVestingBeneficiaryProposed", "id", id,
398		"index", strconv.Itoa(index), "from", who.String(), "to", next.String())
399}
400
401func CancelVestingBeneficiary(cur realm, id string, index int) {
402	who := vestingCaller(cur)
403	r := GetVesting(id, index)
404	if who != r.Beneficiary || r.PendingBeneficiary == "" {
405		panic("launchpad/sales: beneficiary cancellation denied")
406	}
407	r.PendingBeneficiary = ""
408	setVesting(id, index, r)
409	chain.Emit("LaunchpadVestingBeneficiaryCancelled", "id", id,
410		"index", strconv.Itoa(index), "beneficiary", who.String())
411}
412
413func AcceptVestingBeneficiary(cur realm, id string, index int) {
414	who := vestingCaller(cur)
415	r := GetVesting(id, index)
416	if r.PendingBeneficiary == "" || who != r.PendingBeneficiary {
417		panic("launchpad/sales: pending beneficiary only")
418	}
419	old := r.Beneficiary
420	r.Beneficiary = who
421	r.PendingBeneficiary = ""
422	setVesting(id, index, r)
423	chain.Emit("LaunchpadVestingBeneficiaryAccepted", "id", id,
424		"index", strconv.Itoa(index), "from", old.String(), "to", who.String())
425}