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}