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

config.gno

19.00 Kb · 589 lines
  1// Package config is the versioned, governed configuration for Memba Launchpad.
  2// Its admin is the account that published it until a two-step handoff moves
  3// the role. Every lane starts paused and without terms.
  4package config
  5
  6import (
  7	"strconv"
  8	"strings"
  9
 10	"chain"
 11	"chain/runtime/unsafe"
 12	"gno.land/p/samcrew/launchpad/meta/v1"
 13	sm "gno.land/p/samcrew/launchpad/safemath/v1"
 14	"gno.land/r/nt/grc20reg/v0"
 15)
 16
 17const (
 18	LaneDirect     = "direct"
 19	LaneFairSale   = "fairsale"
 20	LaneAirdrop    = "airdrop"
 21	LaneCollection = "collection"
 22	LaneNFTDrops   = "nft_drops"
 23	LaneNFTMarket  = "nft_market"
 24	NativeCurrency = "ugnot"
 25	LedgerTokens   = "tokens"
 26	LedgerNFT      = "nft"
 27	maxCurrencies  = 16
 28	maxIssuers     = 32
 29	issuerPrefix   = "gno.land/r/samcrew/"
 30	maxCreationFee = sm.MaxInt64 / 12
 31)
 32
 33// CurrencyParams holds the terms of one quote currency. A fee of -1 closes
 34// the lane that charges it.
 35type CurrencyParams struct {
 36	Key                 string
 37	DirectCreationFee   int64
 38	FairSaleCreationFee int64 // never below DirectCreationFee: cancelling a sale returns its allocation
 39	FairSaleRaiseCap    int64 // most quote one fair sale may hold in escrow; 0 closes fair sales
 40	CollectionFee       int64
 41}
 42
 43// Params is one immutable snapshot of launch terms. A rate of -1 closes the
 44// lane that charges it.
 45type Params struct {
 46	PrimaryFeeBPS      int64 // share of settled fair-sale proceeds
 47	NFTPrimaryFeeBPS   int64
 48	NFTSecondaryFeeBPS int64
 49	Treasury           address
 50	Currencies         []CurrencyParams
 51}
 52
 53var (
 54	lanes = []string{LaneDirect, LaneFairSale, LaneAirdrop, LaneCollection, LaneNFTDrops, LaneNFTMarket}
 55	// No function of these realms can pay out a balance sent to their address.
 56	unspendable = []string{
 57		"gno.land/r/samcrew/launchpad/config/v1",
 58		"gno.land/r/samcrew/launchpad/tokens/v1",
 59		"gno.land/r/samcrew/launchpad/sales/v1",
 60		"gno.land/r/samcrew/launchpad/nft/v1",
 61		"gno.land/r/samcrew/launchpad/drops/v1",
 62		"gno.land/r/samcrew/launchpad/market/v1",
 63		"gno.land/r/samcrew/launchpad/curation/v1",
 64		"gno.land/r/samcrew/memba_dao",
 65		// The governance core and its bridge to the legacy apps hold no
 66		// treasury and no generic call: nothing there could pay a fee out.
 67		"gno.land/r/samcrew/memba_gov",
 68		"gno.land/r/samcrew/memba_bridge_v1",
 69	}
 70
 71	admin        address
 72	pendingAdmin address
 73	pauser       address
 74	issuers      = map[string]bool{} // "<ledger>:<realm address>"
 75	versions     []Params
 76	allowed      = map[string]bool{}
 77	paused       = map[string]bool{}
 78	verified     = map[string]bool{}
 79	reserved     = map[string]bool{}
 80	owedCeilings = map[string]int64{} // absent: no ceiling
 81)
 82
 83func init() {
 84	admin = unsafe.OriginCaller()
 85	versions = []Params{{PrimaryFeeBPS: -1, NFTPrimaryFeeBPS: -1, NFTSecondaryFeeBPS: -1}}
 86	for _, lane := range lanes {
 87		paused[lane] = true
 88	}
 89	for _, ticker := range []string{"MEMBA", "GNOT", "UGNOT", "WUGNOT", "GNS", "USDC", "USDT", "ATOM", "BTC", "ETH", "SAMCREW"} {
 90		reserved[ticker] = true
 91	}
 92}
 93
 94// caller is the immediate user or realm. Coins attached to a direct call
 95// would land in this realm, which has no way to return them.
 96func caller(cur realm) address {
 97	// The pinned VM already refuses a realm context that is not the live one
 98	// before this line runs. Published code outlives a VM release, so the
 99	// check stays.
100	if !cur.IsCurrent() {
101		panic("launchpad/config: stale realm context")
102	}
103	if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) != 0 {
104		panic("launchpad/config: this call does not accept coins")
105	}
106	return cur.Previous().Address()
107}
108
109func requireAdmin(cur realm) {
110	if caller(cur) != admin {
111		panic("launchpad/config: admin only")
112	}
113}
114
115func unspendableRealm(who address) bool {
116	for _, path := range unspendable {
117		if who == chain.PackageAddress(path) {
118			return true
119		}
120	}
121	return false
122}
123
124// IsUnspendable reports whether a fee or a royalty paid to who could never
125// be paid out again: who is one of the realms listed above, which have no
126// function for that, or it is not an address as the chain writes one, so no
127// caller can ever present it. It is a check for such receivers only, not for
128// the recipient of a transfer: the sales realm is on the list and rightly
129// holds and moves ledger balances of its own.
130func IsUnspendable(who address) bool {
131	return !meta.ValidAddress(who) || unspendableRealm(who)
132}
133
134func validLane(lane string) bool {
135	for _, l := range lanes {
136		if l == lane {
137			return true
138		}
139	}
140	return false
141}
142
143func versionString() string { return strconv.FormatInt(GetCurrentVersion(), 10) }
144
145func validAssetID(id string) bool {
146	if len(id) < 2 || len(id) > 20 || (id[0] != 'T' && id[0] != 'C') || id[1] < '1' || id[1] > '9' {
147		return false
148	}
149	for i := 2; i < len(id); i++ {
150		if id[i] < '0' || id[i] > '9' {
151			return false
152		}
153	}
154	n, err := strconv.ParseInt(id[1:], 10, 64)
155	return err == nil && n > 0
156}
157
158// ProposeAdmin stages a handoff to a user account or a realm. Nothing changes
159// until that exact address accepts, so a target that cannot call is harmless.
160func ProposeAdmin(cur realm, next address) {
161	requireAdmin(cur)
162	if !meta.ValidAddress(next) || next == admin {
163		panic("launchpad/config: invalid next admin")
164	}
165	pendingAdmin = next
166	chain.Emit("AdminProposed", "admin", admin.String(), "pending", next.String())
167}
168
169func CancelAdminProposal(cur realm) {
170	requireAdmin(cur)
171	if pendingAdmin == "" {
172		panic("launchpad/config: no pending admin")
173	}
174	pendingAdmin = ""
175	chain.Emit("AdminProposalCancelled", "admin", admin.String())
176}
177
178func AcceptAdmin(cur realm) {
179	who := caller(cur)
180	if pendingAdmin == "" || who != pendingAdmin {
181		panic("launchpad/config: pending admin only")
182	}
183	previous := admin
184	admin = who
185	pendingAdmin = ""
186	chain.Emit("AdminAccepted", "admin", who.String(), "previous", previous.String())
187}
188
189// validIssuerPath accepts a realm path below the samcrew namespace whose
190// segments are plain lowercase names: nothing that could resolve elsewhere.
191func validIssuerPath(path string) bool {
192	if len(path) > 200 || !strings.HasPrefix(path, issuerPrefix) {
193		return false
194	}
195	for _, segment := range strings.Split(path[len(issuerPrefix):], "/") {
196		if segment == "" {
197			return false
198		}
199		for i := 0; i < len(segment); i++ {
200			c := segment[i]
201			if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '_' {
202				return false
203			}
204		}
205	}
206	return true
207}
208
209// AllowIssuer lists a realm under gno.land/r/samcrew/ as an issuer for one
210// ledger to read. The list only grows, so a ledger can bind what an issuer
211// created to that issuer for good.
212func AllowIssuer(cur realm, ledger, pkgPath string) {
213	requireAdmin(cur)
214	if (ledger != LedgerTokens && ledger != LedgerNFT) || !validIssuerPath(pkgPath) ||
215		len(issuers) >= maxIssuers {
216		panic("launchpad/config: invalid issuer")
217	}
218	issuer := chain.PackageAddress(pkgPath)
219	if issuer == cur.Address() {
220		panic("launchpad/config: invalid issuer")
221	}
222	key := ledger + ":" + issuer.String()
223	if issuers[key] {
224		panic("launchpad/config: issuer already allowed")
225	}
226	issuers[key] = true
227	chain.Emit("IssuerAllowed", "ledger", ledger, "path", pkgPath, "issuer", issuer.String(), "actor", admin.String())
228}
229
230func IsIssuer(ledger string, who address) bool { return issuers[ledger+":"+who.String()] }
231
232func GetAdmin() address        { return admin }
233func GetPendingAdmin() address { return pendingAdmin }
234func GetPauser() address       { return pauser }
235
236func copyParams(p Params) Params {
237	copyP := p
238	copyP.Currencies = make([]CurrencyParams, len(p.Currencies))
239	copy(copyP.Currencies, p.Currencies)
240	return copyP
241}
242
243func GetCurrentVersion() int64 { return int64(len(versions)) }
244
245func GetVersion(version int64) Params {
246	if version < 1 || version > int64(len(versions)) {
247		panic("launchpad/config: unknown version")
248	}
249	return copyParams(versions[version-1])
250}
251
252// One value of the current terms each, so a check can compare its text: the
253// publisher's manual flow reads one before and after each setter. A rate or a
254// fee of -1 is not set; so is every fee of a currency without terms.
255func GetTreasury() address         { return versions[len(versions)-1].Treasury }
256func GetPrimaryFeeBPS() int64      { return versions[len(versions)-1].PrimaryFeeBPS }
257func GetNFTPrimaryFeeBPS() int64   { return versions[len(versions)-1].NFTPrimaryFeeBPS }
258func GetNFTSecondaryFeeBPS() int64 { return versions[len(versions)-1].NFTSecondaryFeeBPS }
259
260// GetTokenTerms is "<direct creation fee>,<fair-sale creation fee>,<raise cap>"
261// in a currency.
262func GetTokenTerms(key string) string {
263	c, ok := findCurrency(versions[len(versions)-1], key)
264	if !ok {
265		return "-1,-1,0"
266	}
267	return strconv.FormatInt(c.DirectCreationFee, 10) + "," + strconv.FormatInt(c.FairSaleCreationFee, 10) + "," + strconv.FormatInt(c.FairSaleRaiseCap, 10)
268}
269
270func GetCollectionFee(key string) int64 {
271	c, ok := findCurrency(versions[len(versions)-1], key)
272	if !ok {
273		return -1
274	}
275	return c.CollectionFee
276}
277
278func AssertExpectedVersion(expected int64) {
279	if expected != GetCurrentVersion() {
280		panic("config changed")
281	}
282}
283
284func validateParams(p Params) {
285	if p.PrimaryFeeBPS < -1 || p.PrimaryFeeBPS > 500 ||
286		p.NFTPrimaryFeeBPS < -1 || p.NFTPrimaryFeeBPS > 500 ||
287		p.NFTSecondaryFeeBPS < -1 || p.NFTSecondaryFeeBPS > 200 {
288		panic("launchpad/config: parameter out of range")
289	}
290	if p.Treasury != "" {
291		if !meta.ValidAddress(p.Treasury) {
292			panic("launchpad/config: invalid treasury")
293		}
294		if unspendableRealm(p.Treasury) {
295			panic("launchpad/config: treasury cannot spend")
296		}
297	}
298	if len(p.Currencies) > maxCurrencies {
299		panic("launchpad/config: too many currencies")
300	}
301	seen := map[string]bool{}
302	for _, c := range p.Currencies {
303		if c.Key == "" || len(c.Key) > 200 || seen[c.Key] ||
304			(c.Key != NativeCurrency && grc20reg.Get(c.Key) == nil) ||
305			c.DirectCreationFee < -1 || c.DirectCreationFee > maxCreationFee ||
306			c.FairSaleCreationFee < -1 || c.FairSaleCreationFee > maxCreationFee ||
307			c.CollectionFee < -1 || c.CollectionFee > maxCreationFee ||
308			c.FairSaleRaiseCap < 0 {
309			panic("launchpad/config: invalid currency terms")
310		}
311		if c.FairSaleCreationFee >= 0 &&
312			(c.DirectCreationFee < 0 || c.FairSaleCreationFee < c.DirectCreationFee) {
313			panic("launchpad/config: fair-sale fee below direct fee")
314		}
315		// With direct <= fair < cap, and no cap while fair sales are closed,
316		// no two of the three can be swapped unnoticed.
317		if c.FairSaleRaiseCap != 0 &&
318			(c.FairSaleCreationFee < 0 || c.FairSaleRaiseCap <= c.FairSaleCreationFee) {
319			panic("launchpad/config: raise cap not above fair-sale fee")
320		}
321		seen[c.Key] = true
322	}
323}
324
325// Terms change only through the setters below. Each takes scalar arguments,
326// so a user account can call it, and appends one snapshot that passed the
327// whole validation; earlier snapshots never change. A launch pins the version
328// it was created under, and an action signed against an older version fails.
329
330func latest() Params { return copyParams(versions[len(versions)-1]) }
331
332func appendVersion(next Params, change string) int64 {
333	validateParams(next)
334	versions = append(versions, next)
335	chain.Emit("ConfigVersionAdded", "version", versionString(), "change", change, "actor", admin.String())
336	return GetCurrentVersion()
337}
338
339func findCurrency(p Params, key string) (CurrencyParams, bool) {
340	for _, c := range p.Currencies {
341		if c.Key == key {
342			return c, true
343		}
344	}
345	return CurrencyParams{}, false
346}
347
348// currencyTerms returns the index of key's terms in p, adding them with every
349// lane closed when the currency is new.
350func currencyTerms(p *Params, key string) int {
351	for i, c := range p.Currencies {
352		if c.Key == key {
353			return i
354		}
355	}
356	p.Currencies = append(p.Currencies, CurrencyParams{
357		Key: key, DirectCreationFee: -1, FairSaleCreationFee: -1, CollectionFee: -1,
358	})
359	return len(p.Currencies) - 1
360}
361
362// SetTreasury names the receiver of protocol fees. Once named it can be
363// replaced, not removed.
364func SetTreasury(cur realm, treasury address) int64 {
365	requireAdmin(cur)
366	if treasury == "" {
367		panic("launchpad/config: invalid treasury")
368	}
369	next := latest()
370	next.Treasury = treasury
371	return appendVersion(next, "treasury")
372}
373
374func SetPrimaryFeeBPS(cur realm, bps int64) int64 {
375	requireAdmin(cur)
376	next := latest()
377	next.PrimaryFeeBPS = bps
378	return appendVersion(next, "primary_fee")
379}
380
381func SetNFTPrimaryFeeBPS(cur realm, bps int64) int64 {
382	requireAdmin(cur)
383	next := latest()
384	next.NFTPrimaryFeeBPS = bps
385	return appendVersion(next, "nft_primary_fee")
386}
387
388func SetNFTSecondaryFeeBPS(cur realm, bps int64) int64 {
389	requireAdmin(cur)
390	next := latest()
391	next.NFTSecondaryFeeBPS = bps
392	return appendVersion(next, "nft_secondary_fee")
393}
394
395// SetTokenTerms prices token creation in one currency and bounds what a
396// single fair sale may raise in it.
397func SetTokenTerms(cur realm, key string, directCreationFee, fairSaleCreationFee, fairSaleRaiseCap int64) int64 {
398	requireAdmin(cur)
399	next := latest()
400	i := currencyTerms(&next, key)
401	next.Currencies[i].DirectCreationFee = directCreationFee
402	next.Currencies[i].FairSaleCreationFee = fairSaleCreationFee
403	next.Currencies[i].FairSaleRaiseCap = fairSaleRaiseCap
404	return appendVersion(next, "token_terms")
405}
406
407func SetCollectionFee(cur realm, key string, fee int64) int64 {
408	requireAdmin(cur)
409	next := latest()
410	i := currencyTerms(&next, key)
411	next.Currencies[i].CollectionFee = fee
412	return appendVersion(next, "collection_fee")
413}
414
415// RemoveCurrency drops a currency's terms, its allowlist entry and its owed
416// ceiling, so adding terms again later neither reopens it nor brings back an
417// old ceiling by itself.
418func RemoveCurrency(cur realm, key string) int64 {
419	requireAdmin(cur)
420	current := latest()
421	next := current
422	next.Currencies = nil
423	for _, c := range current.Currencies {
424		if c.Key != key {
425			next.Currencies = append(next.Currencies, c)
426		}
427	}
428	if len(next.Currencies) == len(current.Currencies) {
429		panic("launchpad/config: unknown currency")
430	}
431	delete(allowed, key)
432	delete(owedCeilings, key)
433	return appendVersion(next, "currency_removed")
434}
435
436// IsLaneReady checks only immutable terms. The immediate allowlist is checked
437// at action time so delisting cannot prevent an old launch's exits.
438func IsLaneReady(lane, currency string) bool {
439	p := versions[len(versions)-1]
440	c, ok := findCurrency(p, currency)
441	if !ok || p.Treasury == "" {
442		return false
443	}
444	switch lane {
445	// A direct fee is what opens a currency to the token lanes: a fair sale
446	// cannot be priced without one, and an airdrop, which is free, needs it too.
447	case LaneDirect, LaneAirdrop:
448		return c.DirectCreationFee >= 0
449	case LaneFairSale:
450		return c.FairSaleCreationFee >= 0 && c.FairSaleRaiseCap > 0 && p.PrimaryFeeBPS >= 0
451	// A collection fee is what opens a currency to the NFT lanes.
452	case LaneCollection:
453		return c.CollectionFee >= 0
454	case LaneNFTDrops:
455		return c.CollectionFee >= 0 && p.NFTPrimaryFeeBPS >= 0
456	case LaneNFTMarket:
457		return c.CollectionFee >= 0 && p.NFTSecondaryFeeBPS >= 0
458	}
459	return false
460}
461
462// Pause closes a lane to new launches and contributions. Claims, refunds and
463// settlements never read the gate, so nothing here can hold funds back.
464func Pause(cur realm, lane string) {
465	who := caller(cur)
466	if !validLane(lane) {
467		panic("launchpad/config: invalid lane")
468	}
469	if who != admin && who != pauser {
470		panic("launchpad/config: pauser or admin only")
471	}
472	paused[lane] = true
473	chain.Emit("LanePaused", "lane", lane, "actor", who.String())
474}
475
476// Unpause opens a lane. It needs a pauser in place and one listed currency
477// whose terms for the lane are complete.
478func Unpause(cur realm, lane, currency string) {
479	requireAdmin(cur)
480	if !validLane(lane) {
481		panic("launchpad/config: invalid lane")
482	}
483	if pauser == "" || !allowed[currency] || !IsLaneReady(lane, currency) {
484		panic("launchpad/config: lane is incomplete")
485	}
486	paused[lane] = false
487	chain.Emit("LaneUnpaused", "lane", lane, "currency", currency, "version", versionString(), "actor", admin.String())
488}
489
490func IsPaused(lane string) bool {
491	if !validLane(lane) {
492		return true
493	}
494	return paused[lane]
495}
496
497// AssertLaneOpen gates a new inflow: the lane is open and the currency is
498// listed with complete terms. A caller that charges the terms in force reads
499// them in the same transaction, as the NFT realms do; one that charges at the
500// version its user signed for checks that version with AssertNewAction, as
501// token creations and fair-sale launches do. The token lanes' exits use the
502// terms recorded at launch and pass no gate.
503func AssertLaneOpen(lane, currency string) {
504	if IsPaused(lane) || !allowed[currency] || !IsLaneReady(lane, currency) {
505		panic("launchpad/config: new action unavailable")
506	}
507}
508
509// AssertNewAction also pins the version whose prices the caller signed for.
510func AssertNewAction(lane, currency string, expectedVersion int64) {
511	AssertExpectedVersion(expectedVersion)
512	AssertLaneOpen(lane, currency)
513}
514
515// SetPauser names the one address, besides the admin, that may close lanes.
516func SetPauser(cur realm, next address) {
517	requireAdmin(cur)
518	if !meta.ValidAddress(next) {
519		panic("launchpad/config: invalid pauser")
520	}
521	pauser = next
522	chain.Emit("PauserChanged", "pauser", next.String(), "actor", admin.String())
523}
524
525func SetCurrencyAllowed(cur realm, key string, value bool) {
526	requireAdmin(cur)
527	if !value {
528		delete(allowed, key)
529	} else if _, ok := findCurrency(versions[len(versions)-1], key); ok {
530		allowed[key] = true
531	} else {
532		panic("launchpad/config: currency terms missing")
533	}
534	chain.Emit("CurrencyAllowed", "currency", key, "allowed", strconv.FormatBool(value), "version", versionString(), "actor", admin.String())
535}
536
537func IsCurrencyAllowed(key string) bool { return allowed[key] }
538
539// SetSalesOwedCeiling bounds what the sales realm may owe in one currency,
540// its fees, escrow, refunds and proceeds together, for a fair-sale
541// contribution to be accepted. Nothing else reads it: settlements, claims,
542// releases and sweeps go through at any ceiling, and a ceiling below what is
543// already owed only stops new contributions. Unlike a pause, it lets running
544// sales take orders while there is room. MaxInt64, the default, is no
545// ceiling. It is not a term a launch signs for, so it adds no version.
546func SetSalesOwedCeiling(cur realm, key string, ceiling int64) {
547	requireAdmin(cur)
548	if _, ok := findCurrency(versions[len(versions)-1], key); !ok {
549		panic("launchpad/config: currency terms missing")
550	}
551	if ceiling < 0 {
552		panic("launchpad/config: invalid ceiling")
553	}
554	if ceiling == sm.MaxInt64 {
555		delete(owedCeilings, key)
556	} else {
557		owedCeilings[key] = ceiling
558	}
559	chain.Emit("SalesOwedCeiling", "currency", key, "ceiling", strconv.FormatInt(ceiling, 10), "actor", admin.String())
560}
561
562func GetSalesOwedCeiling(key string) int64 {
563	if ceiling, ok := owedCeilings[key]; ok {
564		return ceiling
565	}
566	return sm.MaxInt64
567}
568
569func SetVerified(cur realm, assetID string, value bool) {
570	requireAdmin(cur)
571	if !validAssetID(assetID) {
572		panic("launchpad/config: invalid asset ID")
573	}
574	verified[assetID] = value
575	chain.Emit("Verified", "asset", assetID, "verified", strconv.FormatBool(value), "actor", admin.String())
576}
577
578func IsVerified(assetID string) bool { return verified[assetID] }
579
580func SetReserved(cur realm, ticker string, value bool) {
581	requireAdmin(cur)
582	if meta.Ticker(ticker) != nil {
583		panic("launchpad/config: invalid ticker")
584	}
585	reserved[ticker] = value
586	chain.Emit("Reserved", "ticker", ticker, "reserved", strconv.FormatBool(value), "actor", admin.String())
587}
588
589func IsReserved(ticker string) bool { return reserved[ticker] }